Derived from: docs/specs/047-extensible-control-model.md (§14 "Phase 4 — cleanup",
§8, §9, §11.6 / §11.7, §15.6 / §15.7) and the Phase 3 completion tracker in
047-extensible-control-model-implementation.md.
Status: Phase 3 complete (PR #440). Every production element type either routes through V1 dispatch (75 arms), is a composition primitive intentionally above the protocol (8 arms), or sits in the explicit reachable-but-deferred carve list (12 arms). A|B parity (V1 ON ≡ V1 OFF) holds across the full matrix: 9134 xunit + 4410 selftest, 0 failures on both flags.
Phase 4 is the final close-out. It (a) closes the 12 reachable-but-deferred arms so 100% of the V1-reachable surface is registered, (b) flips
UseV1ProtocolON by default and makes it the production path, (c) lands the §8 echo-suppressor elimination and §9EventHandlerStatesplit, (d) lands the §11.7 bucketedElementbase and the §11.6 hard byte gates, (e) deletes the legacyMountXxx/UpdateXxxswitch arms and all A|B testing dead code, (f) graduates the public author surface out of[Experimental]and locks it, and (g) closes every deferred perf/validation gate (ARM64 ratification, AOT publish, macro catch-up).No deferrals inside this close-out. Each task below ships within Phase 4. See "Explicitly out of scope" at the end for the two items intentionally left for follow-up (source generation §7, and the physical
Reactor.Controls.*package split §1.1) with rationale — Phase 4 only guarantees both are unblocked, not executed.
Done & verified (committed):
- §4.10 — final close-out (DONE for everything executable in x64; ARM64 measurement carved out). Dead-code sweep grep-clean across
src+tests(no liveUseV1Protocol/REACTOR_USE_V1_PROTOCOL/ReactorV2/registerBuiltinHandlers/EventHandlerState-monolith — only historical comments;ChangeEchoSuppressorintentionally retained per the §8.3 hybrid, removed from the sweep list). Updated the main tracker header + spec §14 "Phase 4 — cleanup" status to "code-complete; migration closed; V1 is the unconditional production path", reconciling the exit-gate's literal "delete ChangeEchoSuppressor" / "byte gates pass" wording against the settled hybrid + the baseline-machine carve. Tidied a stalePoolPolicyTestsTODO (the real FrameworkElement rent/return reset contract is now covered by the §4.3 self-test fixtures; corrected its "ControlEventState cleared" wording to "preserved"). Full x64 validation: solution build (Reactor.slnx) 0 err; full xunit 9128/0; full selftest 0 fail. Outstanding (handed off, baseline-machine-only): the §4.9 ARM64 perf ratification + the §4.4 §11.6 hard byte-gate measurement.- §4.9 — perf ratification (HANDED OFF; ARM64-baseline-blocked). No code remained to land — all code the §4.9 gates measure is already in place (§11.6 target constants §4.4, perf-project consolidation §4.6, EHS split §4.3, bucketed base §4.4, AOT-clean external proof + CI AOT job §4.7). Speculative perf-tuning (the KD-3 M1 binder-check fold) was deliberately NOT done — it is measurement-gated and prior micro-opts went net-negative. Annotated the §4.9 section with a full handoff + baseline-operator runbook; boxes stay unchecked until the
LAPTOP-4MEP83VIcapture lands. See §4.9 status block.- §4.8 — final author-facing documentation (DONE). Promoted
docs/guide/extensibility-preview.md(hand-maintained — no.md.dttemplate;murunavailable in this env so no generated page touched) from preview to a stable guide: dropped the[Experimental]/flag/breaking-change banner; replaced "enabling V1 / off by default" with a "Dispatch order" section; corrected the pool-reset enumeration (ControlEventStatePRESERVED across rent/return per #114, not cleared) and the per-control event-state section (EventHandlerState→ModifierEventHandlerState+ControlEventStateBox, done); rewroteWriteSuppressedto the §8.3 hybrid; replaced the children table with the final 10 strategies; added a §6.1.1 authoring decision-tree section. UpdatedAGENTS.md: the new-control authoring path (V1 descriptor model), the echo-suppression section (§8.3 hybrid —ChangeEchoSuppressorRETAINED, correcting the task's "deleted" premise), and the per-element-state line. Commit60d0588c.- §4.4 — bucketed
Elementbase + §11.6 byte-gate constants (DONE; gate measurement ARM64-deferred). Bucketed the 14 cross-cutting nullable base fields into a value-equalityElementExtrasrecord behind oneElement.Extensionsslot, using the proven spec-034ElementModifiersSHIM pattern: each field name survives as a public get/init shim (copy-on-write intoExtensions), so all ~180 readers/with-writers compiled & behaved unchanged — onlyElement.cschanged (zero call-site edits, no public-API break). Lean case (Extensions == null) leaves only Key/Modifiers/Extensions at the root (the §11.7 byte win). Added anExtensions is nullfast-path inShallowEqualsfor the hot reconcile diff path; record equality preserved. Renamed the bucketElementExtras(the spec'sElementExtensionsname clashes with the existing fluent-modifier static class). Landed the §11.6 TARGET constants inPerformanceBudgets.cs(407/1520/19200); the merge-blocking ENFORCEMENT/measurement is ARM64-baseline-blocked → §4.9. Validation x64: build 0 err; xunit 9128/0; Animation/Transition/Theme/Context/Attached/Stagger/ Keyframe/Scroll/ConnectedAnimation/Resource selftests 0 fail. Commit60f4a908.- §4.3 — split
EventHandlerState(DONE). Carved the monolithic per-elementEventHandlerStateinto the §9.2 shape. The WinUI true-routed input family (21Current*+ 20 trampolines: pointer/key/tap/focus/ sizechanged/accesskey) was renamed in place toModifierEventHandlerState(ReactorState.Events→Modifiers,GetOrCreateEventState→GetOrCreateModifierState), lazily allocated (null until a routed modifier is wired). Control-intrinsic events now live ONLY on the already-shipped per-controlControlEventStateBoxpayloads: migrated the last 2 live holdouts (Button.Click →ButtonEventPayload.ClickTrampoline; NumberBox immediate flag →NumberBoxEventPayload.ImmediateInnerWired), both resolving the same native-DO-keyedReactorStateso the issue #114/#86 shared-trampoline dedup invariant holds; deleted the dead legacy Image/ScrollViewer/ScrollView Mount/Update/Ensure bodies (descriptors own their wiring) + 5 EHS fields, and the 3 orphaned ToggleSwitch/TextBox EHS fields. Corrected staleControlEventStateBox/payload comments (the box is PRESERVED across pool rent/return per #114, not cleared on return). Added §9.2 hazard self-test fixtures (Spec047EventStateSplitFixtures.cs): no-duplicate-subscription- across-pool-reuse, HandlerType-mismatch reset (+ hot-reload proxy), dual-return idempotency, intrinsic-only alloc-shape (Modifiers==nullwhileControlEventState!=null— the §9.4 proxy for the ARM64-blocked M10/M11 byte measurement), andAddRawRoutedHandlerhandledEventsToo survival (live Handled-leg is a documented TAP SKIP — WinUI 3 can't synthesize input events headlessly; covered by Appium E2EKeyDownTest). Validation x64: core build 0 err; xunit 9128/0; Button/NumberBox/Image/Scroll/Pool/EventHandler + EventStateSplit selftests 0 fail. M10/M11 byte/frequency MEASUREMENT deferred to ARM64 (§4.9). Commits691048bd(split) +90d18d77(fixtures).- §4.2 (part A) — Deleted the ~28 orphaned legacy value-control handler bodies that §4.5 left behind in
Reconciler.Mount.cs/Reconciler.Update.cs(MountToggleSplitButton/Update…, PasswordBox, NumberBox, AutoSuggestBox, RadioButton, RadioButtons, ComboBox, Slider, RatingControl, ColorPicker, CalendarDatePicker, DatePicker, TimePicker, ToggleSwitch, CalendarView + the deadEnsureToggleSwitchWiring/EnsureTextBoxWiringhelpers). These were unreachable (controls dispatch via V1 descriptors; onlyMountCheckBox/UpdateCheckBoxstay — live viaCheckBoxHandlerPath-B — plus the NumberBox immediate-mode chain andSyncSelectedDates, all preserved). This corrects the §4.5 log's "0 orphaned private members remain" over-claim and cuts rawChangeEchoSuppressorrefs from ~55 to the live descriptor surface. Validation: core build 0 err; xunit 9128 pass/0 fail; PrivMount/Echo/NumberBox/CheckBox selftests 0 fail. Commit8a67e34a. (This is part A of §4.2; theChangeEchoSuppressorelimination itself — part B — is DEFERRED, see below.)- §4.2 (part B′) — value-diff echo migration (HYBRID, NOT full elimination). Rather than deleting
ChangeEchoSuppressorwholesale (ruled NO-GO, below), a value-diff echo mechanism was introduced alongside the counter and the safe controlled round-trips migrated onto it. New shared armReactorState.PendingEchoMatch(one-shotFunc<object?,bool>?, reset at the same 3 sites as the counter) +ChangeEchoSuppressor.ArmExpectedEcho/ClearExpectedEcho/ShouldSuppressEcho(counter/scope still wins first, draining a coincident matching arm; else consumes the value predicate).HandCodedControlledPropEntrygained an opt-invalueDiffEchoflag. Migrated: ComboBox, FlipView, GridView, ListBox, Pivot, PipsPager, RadioButtons, SelectorBar, TabView, TemplatedFlipView (+ToggleSwitchHandler;TextBoxHandler/ControlledPropEntryalready value-diff from the PoC). Counter RETAINED (intentional, documented in spec §8.3): Slider/NumberBoxdoublevalues, NumberBox coercion, CalendarView collection, AutoSuggest/ Password/RichEdit strings, Expander, CheckBox path-B,ApplySettersscope, publicWriteSuppressed. Net: a hybrid with noReactorStatebyte win (adds 1 ref field) — chosen for correctness/self-healing on the migrated paths (value-diff cannot strand-and-swallow a real event the way a mis-paired token can); per-control fall-back is to flipvalueDiffEchoback off. Validation: core build 0 err; xunit 9128/0; Echo + ValueDiff + migrated-control (ToggleSwitch/ComboBox/Pivot/TabView/ListBox/RadioButtons/FlipView/GridView/ SelectorBar/PipsPager) selftests 0 fail; DataGrid E2E PASS.- §4.7 — Public V1 author surface graduated + locked: removed all 157
[Experimental("REACTOR_V1_PREVIEW")]attributes across 110src/Reactorfiles and the deadREACTOR_V1_PREVIEWNoWarnfrom all six csprojs. KD-4 (external typed-event surface) was already shipped — the externalMarqueeControlwires a typed CLR event via publicMountContext.BindFor→ReactorBinding<TElement>.OnCustomEvent<TArgs>with no IVT; after the[Experimental]removal theexternal_proofproject also needs noREACTOR_V1_PREVIEWopt-in (strongest form of the proof). External-assembly proof re-validated:Reactor.External.TestControlbuilds clean (0 err, no IL trim/AOT warnings,PublishTrimmed+IsAotCompatibleon); all sixSpec047ExternalProof_Marquee_*selftests green. Analyzers already retired (below). Validation: core build 0 err; xunit 9128/0; ExternalProof selftests 0 fail.- §4.6 — Removed all A|B /
UseV1Protocoldead code.Reconcilernow has a singleReconciler(ILogger? logger = null)ctor (dropped theuseV1Protocol/registerBuiltinHandlersparams, thepublic bool UseV1Protocolproperty, the AppContext-switch read, and the NavigationHost pre-dispatch flag guard); both dispatch sites (Mount.cs:66,Update.cs:117) and both unmount arms no longer gate on the flag. DeletedProgram.csREACTOR_USE_V1_PROTOCOLenv-var mapping, theselftests-v1CI job, the perf A|B duplicates (StressPerf.ReactorV2,BlankReactorV2,DescriptorVariantFactory) +tools/spec047-phase1-checkpoint/(ReactorV2→Reactorin the aggregator/slnx/scripts),V1FeatureFlagTests.cs,TypeRegistryTests.Override_Builtin, and the redundantTextBoxecho-stranding fixture; reshapedV1OnRegistrationTests+ thePorts/*PortTests+ theSpec047V1Protocol/Spec047ExternalProofselftest fixtures tonew Reconciler()with the flag-flipping removed. Grep-clean ofUseV1Protocol/REACTOR_USE_V1_PROTOCOL/ReactorV2outsidedocs/specs/. Validation: core build = 0 err; xunit = 9128 pass/0 fail; Echo + V1_* + Spec047ExternalProof_* selftests = 0 fail. (Perf-project consolidation measurement deferred to ARM64 — see §4.9.)- §4.5 — Deleted the legacy
MountXxx/UpdateXxxdispatch switches: bothMount/Updatenow dispatch V1-registry →_typeRegistry→ composition-primitive-only switch (Component/Func/Memo/ErrorBoundary/ CommandHost/FormField/ValidationVisualizer/ValidationRule). Dead-body sweep removed 32 orphaned legacyMount*/Update*bodies + 2 transitively-dead helpers (~1240 lines); 0 orphaned private members remain acrossReconciler*.cs. Removed the obsolete Phase-2 descriptor-vs-handler parity selftest harness (Spec047V1ProtocolDescriptorFixtures.cs, ~130Desc_fixtures — coupled §4.6 removal; theEcho_real-input regression fixtures were preserved). FixedPrivateUpdateHotPathsreflection fixture (dropped theUpdateSwipeControl/UpdateRefreshContainerlegacy-body probes — those controls are descriptor-driven now). Validation: build = 0 err; xunit V1 ON = 9136 pass/0 fail; full selftest = 0 fail (NativeDockingComposition fixtures are intermittently flaky in full runs — pass deterministically when filtered).- §4.0.1 / §4.0.3 finalized — the genuine overlay port (
OverlayLifecyclestatic module, V1-owned) and the fullTabViewDescriptorport (replacing the deletedTabViewHandler) are landed, and the now-orphaned engine bridges are gone: removed the 14 thin overlay delegators (ContentDialog/Flyout/MenuBar/ CommandBar/MenuFlyout/Popup/CommandBarFlyout × Mount+Update) and the legacyMountTabView/UpdateTabViewbodies fromReconciler.Mount.cs/Update.cs. Overlay leaf helpers (CreateMenuFlyoutItem/UpdateMenuFlyoutItems/CreateAppBarItem/UpdateAppBarItems) were promoted tointernalforOverlayLifecycle;BuildTabHeader/TryUpdatePinHeaderInPlacestayinternalforTabViewDescriptor. Dropped theUpdateCommandBarFlyout/UpdateFlyoutElementPrivateUpdateHotPathsprobes. Validation: build = 0 err; xunit V1 ON = 9136 pass/0 fail; full selftest = 0 fail.
- §4.0.6 parity — full selftest V1 ON = 0 fail; xunit OFF = 9136 pass.
- §4.1 —
UseV1Protocolflipped ON by default (Reconciler.cs~289); flag is now an escape hatch. Fixed 4 OFF-assuming tests (XamlInteropTests×2,TypeRegistryTests.Override_Builtin,RichEditBoxElementTests). xunit ON = 9136 pass/0 fail; full selftest ON = 0 fail. Commit0dee90d8.- §4.0.4 GridView —
GridViewHandlerroutes through the engine's virtualizingMountGridViewbody; addedRareControl_GridViewLazyselftest (500 items/200px → 96 realized, parity ON≡OFF). Commitc9e61e39.- §4.4 spec-hygiene — spec now cites measured §11.6 targets (≤407/≤1520/≤19200); "Phase 5 cleanup" → "Phase 4". Commit
bfdca920.- §4.7 analyzers — RETIRED REACTOR1001/REACTOR1003 (final descriptor API is fully strongly-typed, no source pattern to match); REACTOR1002 remains the active Q10 check. Analyzer tests 4/4. Commit
6b772765. (The other §4.7 items —[Experimental]removal, KD-4, external-assembly proof — landed in the §4.7 commit; see the §4.7 entry above.)- Full solution build (
Reactor.slnx -p:Platform=x64) = 0 errors.🟡 Deferred — needs dedicated, spec-author-involved effort (NOT done):
- §4.2 (part B) — FULL elimination of
ChangeEchoSuppressor. Still NO-GO as a single pass (independent rubber-duck review concurred). Partially addressed by part B′ above: the value-diff mechanism now exists and the safe controlled round-trips are migrated, but the counter is retained for the sites value-comparison cannot model, soChangeEchoSuppressor.csis not deleted. The live surface is ~30 sites across ~20 descriptors + 3 handlers +PropEntry+ the KD-1OnCustomEventdrain + the live CheckBox/NumberBox-immediate/CalendarView bodies + the PUBLICReactorBinding.WriteSuppressedAPI + theEchoSuppressScopeDepthsetter scope. The current counter is a causal token; the spec's proposed "expected Y ± tolerance, suppress one echo" value-compare is causally weaker (a real user event landing on the engine-written value/tolerance would be swallowed → silent state corruption), theApplySettersscope has no value to compare, andWriteSuppressed(UIElement, Action)carries no value/ readback for external authors. The Phase-0 audit CSV is STALE. Prereqs before attempting: refreshed inventory (DONE — seedocs/specs/047/audits/echo-suppressor-phase4-live-sites.md), new regression fixtures for the "real event coincides with expected value" class, and a per-class migration keeping the counter until each class has a proven replacement.
- §4.3 — split
EventHandlerState. Similar magnitude/risk (pervasive, pool-lifecycle hazard #114, monolith deletion gated on full migration). Deferred alongside §4.2 part B.- §4.4 — bucketed
Elementbase + §11.6 hard byte gates. Large surface (Element.cs + all factories + ElementExtensions + reconciler pipelines); the §11.6 byte-gate measurement is ARM64-baseline-blocked regardless.- §4.8 docs / §4.10 close-out. Blocked: both document/sweep the post-§4.2B (
ChangeEchoSuppressorgone) + post-§4.3 (EventHandlerStatesplit) state, which does not yet exist.- §4.9 perf ratification. ARM64 baseline machine (
LAPTOP-4MEP83VI) only — cannot run/validate in this x64 environment.
⚠️ Critical context for §4.0.1 / §4.0.3 (the next work): §4.0 "registration" is currently achieved by Phase-3 prelude delegate/decorator handlers that call back into the legacyMountXxx/UpdateXxxbodies — overlays (Handlers/OverlayDecoratorHandlers.cs), TabView (Handlers/TabViewHandler.cs), GridView (Handlers/GridViewHandler.cs), NavHost, panels (Handlers/PanelDelegateHandlers.cs). This gives byte-identical V1 ON ≡ V1 OFF parity but does not let §4.5 delete the legacy bodies — a genuine port (own the mount/update logic in the handler/descriptor + a new engine strategy) must land first. §4.0.1 needs a new modal-lifecycle decorator strategy; §4.0.3 needs 3 new engine features (post-children mount-hook,ImperativeBridgednamed slots, the spec-045 docking drag/pin pipeline). Keep A|B parity green (run selftest withREACTOR_USE_V1_PROTOCOL=0as the OFF escape hatch) until §4.5 deletes each arm.Build/test cmds (verified this env, dotnet 10.0.204):
- xunit (default = V1 ON now):
dotnet test tests/Reactor.Tests/Reactor.Tests.csproj -p:Platform=x64- selftest V1 ON:
dotnet run --project tests/Reactor.AppTests.Host -p:Platform=x64 -- --self-test [--filter Name]- selftest V1 OFF (escape hatch): set
$env:REACTOR_USE_V1_PROTOCOL="0"first.- Avoid running two
dotnet runselftest builds concurrently — they race on the XamlCompiler DLL and produce spurious failures; run sequentially.
- Every task is a checkbox; mark
[x]only when its artifact (code + tests + doc update, or captured perf result committed underdocs/specs/047/...) is landed and verified. - The A|B parity bar is the safety net for the whole phase. Until §4.6 deletes the legacy arms, every PR must keep V1 ON ≡ V1 OFF green on the full xunit + selftest matrix. Once a legacy arm is deleted (§4.5), its element is V1-only and the parity check for that element retires with it.
- Perf-gated tasks capture results on the Phase 0/2 baseline machine
(
LAPTOP-4MEP83VI, ARM64-native, Release, stable-AC) per the §15.5 runbook, committed underdocs/specs/047/phase4-results/. - Order matters — and the legacy arms must die before the old machinery
does. The legacy
MountXxx/UpdateXxxarms still callChangeEchoSuppressorand use the monolithicEventHandlerState. So you cannot deleteChangeEchoSuppressor.csor theEventHandlerStatestruct while those arms (or the V1-OFF escape path) still exist — it would fail to compile or force wasted migration of soon-to-be-deleted code. The required sequence is:- §4.0 — close the 12 reachable-but-deferred arms (100% registration).
- §4.1 — flip
UseV1ProtocolON by default. - §4.5 — delete the legacy registered arms and remove the V1-OFF path for
them (this strands
ChangeEchoSuppressor/EventHandlerStateto V1-only consumers). - §4.2 / §4.3 — then replace + delete
ChangeEchoSuppressorand split + deleteEventHandlerStateon the surviving V1 path. (The new per-control tolerance metadata and the per-controlControlEventStateBoxcan be built earlier in parallel; only the deletions are gated on §4.5.) - §4.4 — bucketed base + byte gates; §4.6 — flag/A|B dead-code removal; §4.7 — surface lock; §4.8/§4.9 — docs + perf. Each of §4.2/§4.3/§4.4 gates on its own perf budget before its deletion step.
- 100% of the V1-reachable surface (87 arms) is registered and routes through
V1; the 8 composition primitives are the only legacy
MountXxxarms left. UseV1Protocolis ON by default (production path); the feature flag, theregisterBuiltinHandlersinternal ctor, theREACTOR_USE_V1_PROTOCOLenv-var plumbing, theStressPerf.ReactorV2/BlankReactorV2A|B project duplicates, and the dual-flag selftest harness are deleted.ChangeEchoSuppressor.csis deleted; echo handling lives in per-control tolerance/coercion metadata + the ColorPicker shim;WriteSuppressedkeeps its public signature.EventHandlerStateis split per §9.2 (ModifierEventHandlerState+ per-controlControlEventStateBox); M10 shows the EHS-allocation drop.- The §11.7 bucketed
Elementbase ships; the §11.6 hard byte gates pass (≤ Today × 0.4 on M1/M2/M3 measured per §11.6, not the stale §14 estimates). - The public author surface is out of
[Experimental("REACTOR_V1_PREVIEW")], documented as stable indocs/guide/, and KD-4 (external typed-event surface) is closed so a separate assembly can author a multi-event control withoutInternalsVisibleTo. - ARM64 stable-AC ratification capture lands and clears §13 Q1 / §15.6 budgets; AOT publish (1.17 / L13 / L14) and macro catch-up (1.18 / L2/L3/L4/L6) are green on the baseline machine(s).
- Full xunit + selftest + solution build green; the §15.6 regression budgets
hold against the
ReactorTodaybaseline.
Source: the "Path to 100% reachable" list in the Phase 3 tracker
(047-extensible-control-model-implementation.md §"Quantified V1 dispatch
coverage"). These must land before the flip (§4.1) so turning V1 ON by
default does not silently change behavior for any element. Each sub-task keeps
A|B parity (V1 ON ≡ V1 OFF) green for the newly-registered element.
ContentDialog, Flyout, Popup, MenuBar, MenuFlyout, CommandBar,
CommandBarFlyout. These are control-side-mounted (modal lifecycle), not
parent-tree-mounted, so they need a decorator strategy variant beyond the
IDecoratorElementHandler shape used for IconElement.
- Design + ship the modal-lifecycle decorator strategy (engine extension):
a children/host strategy that mounts the overlay's content into the
control-owned slot (
ContentDialog.Content,Flyout.Content,Popup.Child, menuItems, command barPrimaryCommands/SecondaryCommands) and tears it down on dismiss/unmount. (Implemented as a V1-owned static lifecycle moduleCore/V1Protocol/OverlayLifecycle.csholding all 16 mount/update orchestration methods. Per rubber-duck review we use per-handler lifecycle delegation rather than a unifiedChildrenStrategyobject — the overlays' control-owned slots are too heterogeneous (singleContent/Childvs.Itemshosts vs. dualPrimary/Secondarycommand collections) to share one strategy cleanly, and inverting ownership into one module gives genuine V1 ownership with zero duplication. Teardown stays on the engine's type-based unmount recursion (handlers returnContinueDefaultTraversal) to preserve A|B parity — overlay teardown rework is deferred to §4.5 alongside legacy-arm deletion.) - Port
ContentDialogElement(primary/secondary/close button content +Opened/Closing/PrimaryButtonClick/SecondaryButtonClickevents) to a descriptor or hand-coded handler; register inRegisterV1BuiltInHandlers. (Genuine port: legacyMountContentDialog/UpdateContentDialog+ShowContentDialog/ShowContentDialogCoremoved verbatim intoOverlayLifecycle; engine methods are now thin delegators. Handler inOverlayDecoratorHandlers.csowns the logic viaOverlayLifecycle.) - Port
FlyoutElement,PopupElement(single-content overlays;Opened/Closed). (Moved intoOverlayLifecycle; handlers own logic.) - Port
MenuBarElement,MenuFlyoutElement(items hosts with nested menu items +Clickper item). (Moved intoOverlayLifecycle; leaf helpersCreateMenuFlyoutItem/UpdateMenuFlyoutItemsexposedinternal.) - Port
CommandBarElement,CommandBarFlyoutElement(primary/secondary command collections). (Moved intoOverlayLifecycle; leaf helpersCreateAppBarItem/UpdateAppBarItemsexposedinternal static.) - Selftest fixtures
Desc_*/handler tests for all 7; A|B parity green V1 ON ≡ V1 OFF; verify modal open/dismiss + descendant component-state preservation across re-render. (Existing fixtures cover all 7 mount+update and exercise the V1 handler dispatch under V1 ON: ContentDialog (Mount + OpensAtMount + OpensOnStateFlip modal open), Flyout (TargetMounted/Updated + AttachedFlyout + PrivUpdate_PlainFlyout), Popup (Mounted + PopupUpd + SidePopup open/dismiss), MenuBar (Mounted/Initial/Updated/Shrunk menus), MenuFlyout (TargetMounted + NoNewCreations update), CommandBar (CmdBar_ + Issue343 content reconcile), CommandBarFlyout (TargetMounted + PlacementSwap + PrivUpdate_CommandBarFlyout). A|B parity green: all families pass identically V1 ON and V1 OFF (one dockingSidePopup_OpensOnClickflake confirmed flaky — passes on isolated rerun in both modes). xunit 9136 passed / 0 failed V1 ON.)*
Per-instance route/cache/transition state is intercepted in
Reconciler.UnmountRecursive before the V1 dispatch arm.
- Internal-expose
MountNavigationHost/UpdateNavigationHostand wrap as a V1 handler (route/cache/transition state owned by the handler's per-control payload). (Already wired as a Phase-3 prelude delegate handler; Mount/Update delegate to the engine bodies which own the per-control_navigationHostNodespayload.) - Duplicate (or relocate) the
UnmountRecursivecleanup logic into the V1 handler's Unmount so the pre-dispatch intercept can be removed. (ExtractedReconciler.CleanupNavigationHostNode; addedNavigationHostHandler.Unmountcalling it — adapter returns CollectSelf so no double child recursion.) - Remove the
UnmountRecursiveintercept; register inRegisterV1BuiltInHandlers. (Handler already registered. The flag-independent intercept is now a!UseV1Protocolfallback — full removal deferred to §4.6 with the V1-OFF escape path, keeping cleanup byte-identical V1 ON ≡ V1 OFF.) - Selftest: navigation push/pop/back-stack + cache eviction parity V1 ON ≡ V1 OFF; verify no leaked state across re-mount. (NavHost selftests 16/16 green under both flags; NavigationHostTests+UseNavigationTests 30/30 pass.)
Descriptor exists but registration is carved (bisect ratified the documented gaps are hot in the docking suite). Closing needs engine work.
- Engine: post-children mount-hook so
SelectionChangedsubscribes after children are added (avoids spurious selection echo at mount). (Already shipped: theAfterChildrenMounthook is dispatched inV1HandlerAdapterafterDispatchChildrenMount. For TabView theTabItemsHostbinder is anIItemsBinderStrategy, soDescriptorHandlerruns it INLINE before the prop loop — tabs are added, then the prop loop writesSelectedIndex(echo-suppressed), thenEnsureSubscribedwiresSelectionChangedafterward. No spurious mount-time echo; the explicit hook is available but unneeded for this binder ordering.) - Engine:
.ImperativeBridgednamed-slot support forTabStripHeader/TabStripFooterElement slots. (Already shipped onControlDescriptor; the descriptor now uses two.ImperativeBridgedentries that mount on first render andReconcileV1Childon update, mirroring the legacyReconcileChildslot semantics including clear-on-null.) - Port the spec 045 §2.4 docking drag pipeline trampolines
(
OnTabDragStarting/OnTabDragCompleted) into the descriptor. (Two.HandCodedEvententries + two payload trampoline slots (TabDragStartingTrampoline/TabDragCompletedTrampoline). Bodies are byte-identical to the legacyMountTabViewarms — seed theDataPackage(RequestedOperation = Move, sentinel text) so externalAllowDroptargets accept the drop, fire with idx (-1tolerated on the tear-out completion path).) - Port spec 045 §2.2 pinnable headers (
BuildTabHeader/BuildPinButton/ in-placeTryUpdatePinHeaderInPlace). (CreateContainerbuilds the header viaReconciler.BuildTabHeader;UpdateContainerdoes the focus-preserving in-place refresh viaReconciler.TryUpdatePinHeaderInPlacewith the same rebuild/string fallbacks. Both helpers promoted tointernal static.) - Port conditional
SelectedIndexwrite + in-placeCanUpdatefor tab content (preserve focus/state on re-render). (SelectedIndexvia.HandCodedControlled(conditional readback-gated write + echo suppression); per-tab content reconciled in place byTabItemsHostviaReconcileV1Child, reassigningContentonly on realized-control change.) - Register
TabViewDescriptor; re-run the docking selftest suite (DockHooks / PixDoc / RoleAware / Composition / FloatRoot) 3× clean V1 ON; A|B parity green. (Registered viaRegisterDescriptor(TabViewDescriptor.Descriptor); retired the delegateTabViewHandler(file deleted). Validated: TabView fixtures 0 fail V1 ON; Composition suite 0 fail (isolated) V1 ON; RoleAware 0 fail on reruns (the occasional single wandering fixture is pre-existing headless-harness flakiness, identical under V1 OFF); xunit 9136 passed / 0 failed V1 ON.)
Descriptor exists but the ItemsHost<> strategy pre-mounts every item (no
virtualization); the legacy MountGridView uses
ItemsSource = Range(0..N) + ItemTemplate + ContainerContentChanging for lazy
realization. Production memory/lifecycle would silently regress.
- Choose and ship one: a hand-coded
GridViewHandlermirroringListViewHandler's CCC virtualization, or a reusableRecyclingItemsHost<>ChildrenStrategy that wraps theItemsSource+ContainerContentChangingrealization contract (preferred if it can also back other lazy items hosts). (Shipped the hand-codedGridViewHandlermirroringListViewHandler— it routes through the engine'sMountGridView/UpdateGridViewbody which installs the sameItemsSource = Range(0..N)+ sharedItemTemplate+ContainerContentChanginglazy-realization contract as ListView. The descriptor's non-virtualizingItemsHost<>strategy is intentionally NOT registered.) - Re-point
GridViewDescriptorat the virtualizing strategy; register inRegisterV1BuiltInHandlers. (TheGridViewHandleris registered inRegisterV1BuiltInHandlers; the non-virtualizingGridViewDescriptorstays unregistered. Genuine descriptor port deferred — the handler already delivers virtualization parity.) - Selftest: a GridView-at-scale fixture (≥ a few hundred items) asserting
lazy container realization (only realized containers mounted), to lock the
lifecycle that the current A|B fixtures don't stress. (Added
RareControl_GridViewLazy: 500 items in a 200px viewport → only 96 realized (< total/2), tail item unrealized, first item realized. Identical 96/500 under V1 ON and V1 OFF — A|B parity green.)
XamlHostDescriptor / XamlPageDescriptor exist but stay unregistered because
XamlInterop.Register(reconciler) populates the external _typeRegistry at
startup; auto-registering V1 would clash via EnsureRegistrableElementType.
- Decide the single ownership path: either V1 auto-registration owns the two
interop element types (and
XamlInterop.Registerstops populating_typeRegistryfor them), orXamlInterop.Registerbecomes a V1-handler registration. Avoid the duplicate-registration throw. Decision: V1 auto-registration owns them (RegisterDecoratorHandlerforXamlPageElement/XamlHostElementinRegisterV1BuiltInHandlers);XamlInterop.Registeris now idempotent (skips types already registered via newReconciler.IsElementTypeRegistered), so it stays a safe public API. - Register the two interop descriptors via the chosen path; remove the
_typeRegistryclash. - Selftest: XAML interop host/page mount + interop bridge parity V1 ON ≡
V1 OFF. (
Hosting_XamlInteropRegistergreen both flags; xunitXamlInteropTests+V1OnRegistrationTestsgreen, +3 new V1-ON tests.)
- Re-derive the dispatch-coverage table: confirm 87/87 V1-reachable arms are
registered (75 → 87) and only the 8 composition primitives remain on the
legacy switch. (All V1-reachable arms register via Phase-3 prelude
delegate/decorator handlers — overlays
OverlayDecoratorHandlers.cs,NavigationHostHandler,TabViewHandler,GridViewHandler, panelsPanelDelegateHandlers.cs, XamlHost/Page decorators. Genuine descriptor ports for §4.0.1/4.0.3/4.0.4 are gated to §4.5 — see those sections.) - Full xunit + selftest matrix green V1 ON ≡ V1 OFF at 100% registration (this is the last A|B parity checkpoint before the flip). (Full selftest V1 ON = 0 failures; xunit OFF baseline = 9136 passed/0 failed. Docking float/A11y selftests are flaky under full-suite load but green in isolation.)
Source: spec §14 Phase 4 ("the production swap"). Gated on §4.0 complete.
- Change the default in
Reconcilerctor (Reconciler.cs:287-290) fromUseV1Protocol = falsetotruewhen neither the explicit ctor flag nor the AppContext switch is set. (Done —elsebranch of ctor flag resolution now setsUseV1Protocol = true.) - Update the AppContext-switch semantics: the switch (and explicit ctor
flag) now exists only as an escape hatch to turn V1 OFF during the
legacy-deletion window (§4.5); once §4.5 deletes the legacy arms, OFF is
no longer a valid runtime state and the flag is removed (§4.6). (Ctor XML
doc updated to escape-hatch semantics;
switch=falsestill forces OFF.) - Run the full xunit + selftest suite with the new default; confirm green.
(xunit ON = 9136 passed/0 failed after fixing 4 OFF-assuming tests:
XamlInteropTests×2,TypeRegistryTests.Override_Builtin_Type,RichEditBoxElementTests. Full selftest ON = 0 failures.) - Capture an advisory perf snapshot at the flip (production default) to
anchor the §4.9 ratification baseline. (Deferred to §4.9 — the ARM64
stable-AC ratification on
LAPTOP-4MEP83VIis the authoritative anchor; a flip-point snapshot on non-baseline hardware would not be comparable.)
Note: between §4.1 and §4.5, V1 OFF still functions (legacy arms not yet deleted) so a regression can be bisected by flipping the flag. After §4.5, the flip is permanent and the flag is gone.
Status (Phase 4 close-out session): Part A landed (commit
8a67e34a) — the orphaned legacy value-control handler bodies were deleted (see progress log). Part B′ (value-diff migration of the SAFE paths) landed (commitc5c1399e) — a value-diff echo mechanism (ReactorState.PendingEchoMatch+ArmExpectedEcho/ClearExpectedEcho/ShouldSuppressEcho, opt-invalueDiffEchoonHandCodedControlledPropEntry) now handles the synchronous, exact-comparable, single-controlled-value round-trips: ComboBox, FlipView, GridView, ListBox, Pivot, PipsPager, RadioButtons, SelectorBar, TabView, TemplatedFlipView + ToggleSwitchHandler (TextBox/ControlledPropEntrymigrated earlier in79e9cc9b/a24bb1fa). See spec §8.3 for the implemented direction. Part B (the FULLChangeEchoSuppressorelimination below) remains DEFERRED — the counter is intentionally RETAINED as the fallback for the sites value-diff cannot model (doubles, coercion, collection batch, deferred/coercion strings, Expander, CheckBox path-B, theApplySettersscope, and the publicWriteSuppressedprimitive — all enumerated in spec §8.3). The end state is a documented hybrid, soChangeEchoSuppressor.csis NOT deleted and there is noReactorStatebyte win (the value-diff arm adds one ref field). The original "delete + tolerance metadata + ColorPicker shim" plan below is therefore superseded by §8.3 and its boxes stay unchecked (full elimination would still need new regression coverage for the coercion/collection/public-API classes). The refreshed live call-site inventory (the CSV cited below is stale) is indocs/specs/047/audits/echo-suppressor-phase4-live-sites.md.
Part B′ — value-diff migration of the safe paths (LANDED, commit c5c1399e):
- Shared value-diff arm on
ReactorState(PendingEchoMatch, one-shotFunc<object?,bool>?), reset at the same 3 sites asEchoSuppressCount. -
ChangeEchoSuppressor.ArmExpectedEcho/ClearExpectedEcho/ShouldSuppressEcho(counter/scope wins first and clears a coincident arm; else consumes the one-shot predicate). Opt-invalueDiffEchoonHandCodedControlledPropEntry+ theHandCodedControlledbuilder. - Migrate the synchronous/exact/single-value descriptors + ToggleSwitchHandler (10 descriptors + 1 handler listed above) to value-diff.
- Strand-safety fixes (code-review): unconditional arm clear in the
counter/scope branch of
ShouldSuppressEcho; post-write readback clear inHandCodedControlledPropEntry.Updatefor guarded/coerced no-op writes. - Document the hybrid + retained-counter rationale in spec §8.3.
- Regression fixtures
ValueDiff_ComboBox_Drift,ValueDiff_ToggleSwitch_Drift,ValueDiff_GridView_GuardedNoOpStrand(+ existing TextBox/RadioButton/ ToggleSplitButton drift fixtures). Validated x64: build 0 err; xunit 9128/0; ValueDiff + Echo + migrated-control selftests 0 fail; DataGrid E2E pass.
Part B — full ChangeEchoSuppressor elimination (DEFERRED; superseded by §8.3):
Source: spec §8 (Resolved §13 Q3) + the audit
docs/specs/047/audits/begin-suppress-audit.csv (24 call sites). Phase 1
KD-1 (OnCustomEvent drains ChangeEchoSuppressor.ShouldSuppress) migrates
here.
Ordering: the per-control tolerance/coercion metadata + ColorPicker shim can be built before §4.5, but deleting
ChangeEchoSuppressor.csis gated on §4.5 (legacy arms still callBeginSuppress/ShouldSuppress).Counts are from the audit CSV (24 rows):
eliminable-tight-diff12 +defensive-redundant1 = 13 trivial deletions;coercion4 +float-precision4 = 8 tolerance sites;items-coercion2; and 1user-state-races-render(ColorPicker). The spec §8 prose table citeseliminable-tight-diff: 14, which disagrees with the CSV's 12 — reconcile in the §4.4 spec-hygiene task; the CSV is the source of truth.
- Trivial deletions (13 sites). Delete the
BeginSuppresscall at the 12eliminable-tight-diffrows + the 1defensive-redundantrow (AutoSuggestBox.Text) per the audit CSV. Each is already covered by the element-prop diff / handler-sidelastFired != tag.Xcheck. - Coercion + float-precision metadata (8 sites). Add per-control
tolerance/coercion metadata to the descriptor/handler: NumberBox/Slider
declare
coercedBy: [Minimum, Maximum]; the 4 float-precision sites declare a numeric tolerance (match today'sAreNumberBoxValuesEquivalent). Engine records "expected Y, suppress one echo for Y ± tolerance." -
items-coercion(2 sites).CalendarView.SelectedDateskeeps a per-control imperative shim (diff semantics don't generalize); fold the existing.CollectionDiffControlledper-element suppression into the shim so it no longer depends onChangeEchoSuppressor. -
user-state-races-render(1 site — ColorPicker). Replace the suppressor with a per-handlerexpectedColorcapture + tolerance compare. - Re-implement
ReactorBinding<T>.WriteSuppressed(§13 Q19). Swap its body offChangeEchoSuppressor.BeginSuppressonto the per-control tolerance/coercion mechanism. Signature unchanged — existing callers and external authors are source-compatible. - Migrate KD-1. The interim
ShouldSuppressdrain insideReactorBinding<T>.OnCustomEvent/.HandCodedControlled/.CoercingOneWaytrampolines moves to the descriptor-declared echo shape. - Delete
ChangeEchoSuppressor.csand theEchoSuppressCountfield onReactorState(§11.3 −4 bytes) — after §4.5. Confirm no remaining references. - Validation. M9 (
Update_AllChanged) + the §15.8 Q3 correctness pair (Echo_Coercion_Slider,Echo_UserStateRacesRender) + M13 (Setters_Suppression_Scope, callback count = 0) all pass. No new echo regressions in the value-bearing selftest fixtures (ToggleSwitch, Slider, NumberBox, ColorPicker, ComboBox, PasswordBox, AutoSuggestBox, CalendarView).
Source: spec §9 + the EventHandlerState field audit (Phase 0 deliverable 0.2).
Ordering: the new
ModifierEventHandlerState+ per-controlControlEventStateBoxcan be built before §4.5, but deleting the monolithicEventHandlerStatestruct is gated on §4.5 (legacy arms still use it).
- Introduce
ModifierEventHandlerStateholding only the WinUI true-routed event family (pointer / key / tap / focus / context / manipulation / drag); lives onReactorState, allocated lazily (null until a routed-input modifier is wired). (Implemented as an in-place RENAME of the surviving monolith: after evicting the 9 control-intrinsic fields the struct holds only the 21Current*+ 20 routed trampolines, soEventHandlerState→ModifierEventHandlerState,ReactorState.Events→Modifiers,GetOrCreateEventState→GetOrCreateModifierState. Already lazy — the field is nullable and allocated only byGetOrCreateModifierStatefromApplyEventHandlers/Bind*. Commit691048bd.) - Move control-intrinsic (plain CLR) events out of the shared struct into
per-control payloads stored in
ReactorState.ControlEventState(ControlEventStateBoxwithHandlerTypediscriminator +Payload), per §9.2. Reuse the existing per-control payload classes inControlEventPayloads.cs(already used by descriptors / hand-coded handlers) — the discriminator matches regardless of which shape authored the mount (§9.2.1). (The per-control box + payloads were already shipped (Phase 1/1.7); this completed the migration of the last live holdouts: Button.Click →ButtonEventPayload.ClickTrampoline, NumberBox immediate flag →NumberBoxEventPayload.ImmediateInnerWired. Image/ScrollViewer/ ScrollView were already descriptor-wired, so their dead legacy Mount/Update/Ensure bodies + EHS fields were deleted; ToggleSwitch/TextBox EHS fields were orphaned and deleted. Commit691048bd.) - Define + test the pool event-state lifecycle precisely. Specify
whether native event subscriptions are unsubscribed on return, retained
with reset payloads, or re-wired on rent — the current pool deliberately
preserves trampolines to avoid double-subscribe, so the §9.2 reset contract
must not reintroduce issue #114. (Contract confirmed + corrected the stale
comments that claimed
ControlEventStateis cleared on return: it is PRESERVED across rent/return (#114) so the lifetime-subscribed trampoline reads the LIVE element viaGetElementTag;Modifiers?.ClearCurrentHandlers()nulls only theCurrent*user delegates; the box is dropped only on full detach / replaced on aHandlerTypemismatch. TheEventStateSplit_NoDuplicateSubscriptionAcrossPoolReusefixture asserts no duplicate native subscription across rent/return. Commit90d18d77.) - Cover the four §9.2 hazards with tests: pool reuse (no previous-tenant
state), handler override (stale-
HandlerType→ deterministic reset, notInvalidCastException), hot-reload type-identity change (reset across the version boundary), and dual-RCW idempotency (return is idempotent, no double-clear). (Self-test fixtures:…NoDuplicateSubscriptionAcrossPoolReuse,…HandlerTypeMismatchResetsBox(also the hot-reload type-identity proxy),…DualReturnIdempotent. All green x64. Commit90d18d77.) - Verify the
AddRawRoutedHandlerescape hatch (§9.5 / Q11) onMountContext/UpdateContext(already present insrc/Reactor/Core/V1Protocol/MountContext.cs) survives the split and is covered by ahandledEventsTootest (child Handled-marksKeyDown, parent.OnKeyDownAnystill fires). (FixtureEventStateSplit_AddRawRoutedHandler_HandledEventsTooasserts the hatch is intact on both contexts and split-independent (targetModifiersstays null). The live Handled-child→parent leg is a documented TAP SKIP — WinUI 3 cannot synthesize aKeyRoutedEventArgs/RaiseEventan input event headlessly; that leg is covered by the Appium E2EKeyDownTest. Commit90d18d77.) - Delete the monolithic
EventHandlerStatestruct once all events route through the split — after §4.5. (Done via the in-place rename: the monolith no longer exists — only the routed-familyModifierEventHandlerStateremains; noEventHandlerStatereference survives anywhere insrc. Commit691048bd.) - Validation. M10 (
EventHandlerState_Alloc) shows the headline drop (≈424 B → ≈32 B per-control table;ModifierEHSnot allocated for the common case). M11 (ModifierEHS_Frequency) confirms < 20% of elements in a representative 1000-element tree allocateModifierEventHandlerState. Routed-event bubbling fixture (§9.3) green. (Code-complete; the byte/ frequency MEASUREMENT (M10/M11) is ARM64-baseline-blocked and deferred to §4.9. The alloc-SHAPE is asserted instead in this x64 env byEventStateSplit_ModifierStateLazyForIntrinsicOnly: an intrinsic-only control leavesReactorState.Modifiers == nullwhileControlEventStateis allocated; a routed-modifier control allocatesModifiers. xunit 9128/0; affected-control + Pool + EventHandler selftests 0 fail. Commit90d18d77.)
Source: spec §11.6 / §11.7 + §15.6 ("§11.6 targets become hard gates at cleanup"). The byte targets depend on §4.2 (echo) + §4.3 (EHS split) + the bucketed base landing.
- Bucket the 14–16 cross-cutting nullable
Elementbase fields (Attached,ThemeBindings,ImplicitTransitions,ThemeTransitions,LayoutAnimation,AnimationConfig,ElementTransition,InteractionStates,StaggerConfig,KeyframeAnimations,ScrollAnimation,ConnectedAnimationKey,ResourceOverrides,ContextValues) into a single nullableElementExtensionssub-record (mirroring spec 034'sElementModifiers). In the lean case (Extensions == null) the base shrinks from ~128 B to ~16 B (onlyKeyandModifierssurvive at the root). (Done — bucketed into a value-equalityElementExtrasrecord (renamed from the spec'sElementExtensionsto avoid the existingElementExtensionsstatic fluent-modifier class) exposed via oneElement.Extensionsslot; lean case carries only Key/Modifiers/Extensions. Commit60f4a908.) - Migrate all readers/writers of the bucketed fields to the sub-record
(factory methods, fluent modifiers in
ElementExtensions.cs, reconciler apply pipelines). Preserve external behavior; no API break to authors. (Done via the provenElementModifiersSHIM pattern: each of the 14 field names survives as a public get/init shim onElement(get => Extensions?.X; init => copy-on-write into Extensions), so all ~180 existing readers andwith-expression writers — incl. the read-then-write composites — compile and behave UNCHANGED with zero call-site edits; onlyElement.cschanged. Public API preserved. Added anExtensions is nullfast-path inShallowEqualsfor the hot reconcile diff path. Commit60f4a908.) - Land the §11.6 hard byte gates as merge-blocking on M1/M2/M3, measured
per §11.6 (
Target = min(Direct + 100, ReactorToday × 0.4)— i.e. the measured ≤407 / ≤1520 / ≤19200, not the stale §14 ≤100/≤320/≤500 estimates). (Code-complete: the §11.6 TARGET constants are landed insrc/Reactor/Core/PerformanceBudgets.cs(407/1520/19200). MEASURED onLAPTOP-4MEP83VIARM64 (PR #465 — indicative capture 2026-05-29,docs/specs/047/phase4-results/.../2026-05-29-arm64/): per-render alloc is M1 1,289 B (3.2× over 407 — FAIL), M2 3,687 B (2.4× over 1,520 — FAIL), M3 8,530 B (≤19,200 — PASS). The gates are therefore NOT met for M1/M2 — box stays open. Closing it needs the M1/M2 leaf-alloc follow-up (the deferred KD-3 M1 binder-check fold + investigating the M1 +20% / M12 +17% alloc regressions vs the pre-bucketing baseline) and an isolated stable-AC re-capture to wire the merge-blocking enforcement. Commit60f4a908.) - Spec hygiene: update spec §14 "Phase 4 — cleanup" to cite the measured
§11.6 targets instead of the stale
≤100 / ≤320 / ≤500, and fix the §15.6 "Phase 5 cleanup" reference to read "Phase 4" (this spec has no Phase 5). (Done — spec §14 cleanup bullet, §15.1 goal 1, the §15.6 hard-gate sentence, and the §15.7 Phase 4 row now cite ≤407/≤1520/≤19200; the "Phase 5 cleanup" reference now reads "Phase 4 cleanup".) - Validation. M1/M2/M3 pass the hard gates on the baseline machine;
L4/L5 working-set within the §15.6 budgets; M7 (no-change update) ≤ Today.
(PARTIAL — alloc axis measured on
LAPTOP-4MEP83VIARM64 (PR #465): the §15.6 "M1–M3 alloc ≤ Today" budget is met for M2 (−5.1%) and M3 (−6.0%) but FAILS for M1 (+20.3%); the absolute hard gates remain unmet for M1/M2 (above). L4/L5 (working-set) and M7 (timing) are NOT ratified — the macro projects were deleted in Phase 4 and the timing axis is environment-throttled in the non-isolated capture. The original x64 CORRECTNESS validation stands. In this x64 env the bucketing is validated for CORRECTNESS: build 0 err; xunit 9128/0; the full animation/transition/theme/context/attached/stagger/keyframe/scroll/ connected-animation/resource selftest families 0 fail.)
Source: spec §14 Phase 4 ("Delete the private switch"). Gated on §4.0 (100% registration) + §4.1 (flip) being stable.
- Delete the legacy
MountXxx/UpdateXxxarms inReconciler.Mount.cs/Reconciler.Update.csfor every element registered through V1 (the 87 reachable arms). Keep only the 8 composition-primitive arms (Component,Func,Memo,ErrorBoundary,CommandHost,Validation.FormField/ValidationVisualizer/ValidationRule) and theModifiedElementunwrap at the top ofMount(not a switch arm). - Delete the now-unreachable dispatch fallthrough (the
elselegacy switch branch) once no registered element relies on it; the dispatch becomes V1-registry → external_typeRegistry→ composition-primitive switch. - Remove any internal helpers that only the deleted arms used (dead-code
sweep —
ApplyDefaultAutomationNamevariants, legacy per-control wiring helpers, etc., that the V1 handlers don't call). - Validation. Full xunit + selftest green (V1-only now — A|B parity no
longer applicable for deleted arms); solution build green; no orphaned
internalmembers flagged by the analyzer / unused-symbol pass.
The A|B harness existed only to diff V1 ON vs V1 OFF on one binary. With V1 the production default and legacy arms deleted, all of it is dead.
- Remove the
Reactor.UseV1ProtocolAppContext switch read, thepublic bool UseV1Protocolproperty, and theuseV1Protocolctor parameters fromReconciler(Reconciler.cs:250-296, 568). V1 is unconditional. (SingleReconciler(ILogger? logger = null)ctor remains.) - Remove the internal
Reconciler(logger, useV1Protocol, registerBuiltinHandlers)A|B ctor and theregisterBuiltinHandlersplumbing; built-in handler registration is unconditional. (The Phase 2 descriptor-vs-handler harnessDescriptorVariantFactorythat usedregisterBuiltinHandlers: falsewas deleted; the echo-stranding fixtures migrated tonew Reconciler()against the now-built-in descriptors.) - Remove the
REACTOR_USE_V1_PROTOCOLenv-var mapping intests/Reactor.AppTests.Host/Program.cs:11-22. - Remove the dual-flag selftest harness (removed the
selftests-v1CI job in.github/workflows/ci.yml; de-switchedSpec047V1ProtocolFixtures/Spec047ExternalProofFixturesso they no longer flip the AppContext switch; removed the redundantTextBoxecho-stranding fixture). - Delete the A|B perf project duplicates:
tests/stress_perf/StressPerf.ReactorV2andtests/startup_perf/BlankReactorV2. Folded their scenarios back into the primaryStressPerf.Reactor/BlankReactor—ReactorV2is nowReactor. Updated the perf aggregator (§15.6) so it comparesDirect/ReactorToday(historical baseline)/Reactor(current)without a live V2 variant. (Code complete; perf measurement/ratification deferred to the ARM64 baseline machine — see §4.9.) - Delete or repurpose the V1-flag-specific test files:
deleted
tests/Reactor.Tests/Spec047/V1Protocol/V1FeatureFlagTests.cs; reshapedPorts/V1OnRegistrationTests.cs(kept the registration-shape / XAML-interop behavior tests, dropped the flag/OFF assertions and theSpec047V1FlagCollection); migrated the remainingPorts/*PortTests.cstonew Reconciler()and dropped theFlag_Offcases; deletedTypeRegistryTests.Override_Builtin_Type_Mount_Is_Dispatched(the V1-OFF legacy-override escape hatch). - Remove the
tools/spec047-phase1-checkpoint/A|B checkpoint runner. - Validation. Solution builds with zero references to
UseV1Protocol/REACTOR_USE_V1_PROTOCOL/ReactorV2outsidedocs/specs/(grep clean); core build green, xunit green (9128 pass / 0 fail), and the affected selftest fixtures green (Echo, V1_, Spec047ExternalProof_).
Source: Phase 1 exit gate item 5 (surface marked provisional; lock after Phase 2 decision) — Phase 2 decided, so Phase 4 locks it. Includes KD-4.
- Remove
[Experimental("REACTOR_V1_PREVIEW")]from the public V1 surface (IElementHandler<,>,MountContext/UpdateContext,ReactorBinding<T>,ControlDescriptor<,>+ builder methods,RegisterType/RegisterHandler/RegisterHandlerForDerivedTypes, pool-policy API,WriteSuppressed,AddRawRoutedHandler). The surface is now stable / supported. (Swept all 157 attribute occurrences across 110src/Reactorfiles — public and internal — graduating the whole V1 feature; dropped the now-deadREACTOR_V1_PREVIEWNoWarnfrom all six csprojs:Reactor.csproj,Reactor.Tests,Reactor.AppTests.Host,PerfBench.ControlModel, and bothexternal_proofprojects.) - Close KD-4 — external typed-event surface. Ship the public typed-event
wiring so an external assembly can author a multi-event control (the
.HandCodedControlled/.HandCodedEventper-descriptorTPayloadshape, orOnCustomEventwith a pool-safe deduped trampoline) withoutInternalsVisibleToon Reactor internals. (Already shipped: the externalMarqueeControlauthors a typed CLR event viaMountContext.BindFor(...)→ReactorBinding<TElement>.OnCustomEvent<EventArgs>(...)— both public — and registers throughReconciler.RegisterHandler<,>. TheReactor.External.TestControlproject has only a plainProjectReferenceto Reactor (no IVT) and, after the[Experimental]removal, noREACTOR_V1_PREVIEWopt-in either.ReactorBinding<TElement>'s ctor stays internal but is reached via the publicBindFor, so it is not a gap. TheSpec047ExternalProof_Marquee_WriteSuppressedfixture exercises the pool-safe deduped trampoline.) - Activate / retire the compile-time validation analyzers (§13 Q10).
REACTOR1001(StringEventReferenceAnalyzer) andREACTOR1003(ControlledReadBackTypeAnalyzer) are still documented no-ops "until Phase 2" (src/Reactor.Compile.Analyzer/*.cs). Q10 requires compile-time validation to be real, not a runtime failure. Either activate the rule bodies (flag string-form event/property typos + controlled read-back type mismatches as compile errors) with "should-fail" analyzer-test fixtures, or prove they are obsolete because the final descriptor API is fully strongly-typed (no string-form references remain) and remove the reserved no-op rules + their fixtures. Document the decision. Decision: RETIRED both. The final descriptor API is fully strongly-typed — there is nochangeEvent: "string"parameter (events wire via typedsubscribelambdas referencing the real CLR event, e.g.((Slider)fe).ValueChanged += ...), andControlled<TValue, TArgs>unifies theset: Action<TControl,TValue>andreadBack: Func<TControl,TValue>generic so the C# compiler already rejects a read-back type mismatch at the call site. A repo-wide sweep found zero string-form event references in production descriptors. Both rules had no source pattern left to match. RemovedStringEventReferenceAnalyzer.cs,ControlledReadBackTypeAnalyzer.cs, their*AnalyzerTests.csfixtures, theStringEventReference/ControlledReadBackTypedescriptors, and the REACTOR1001/1003 rows fromAnalyzerReleases.Unshipped.md+ the guide table. REACTOR1002 (CustomEventDelegateTypeAnalyzer) remains as the active, real Q10 compile-time check (typed-event EventArgs validation). Analyzer tests green (4 pass). - Verify the external-assembly proof (Phase 1 gate item 2) still passes with
the locked surface: a control hosted in a separate assembly, registered via
public API, exercising value writes / events / modifiers / setters /
pooling / child reconciliation, with
PublishTrimmed=true+IsAotCompatible=trueand zero new trim/AOT warnings. (Reactor.External.TestControlbuilds clean — 0 errors, no IL2xxx/IL3xxx trim/AOT warnings, only the pre-existing core doc-comment crefs — withPublishTrimmed/IsAotCompatibleset and noREACTOR_V1_PREVIEWopt-in. All sixSpec047ExternalProof_Marquee_*selftests green; the AOT-published run is covered by the existing.github/workflows/ci.ymlAOT selftest job, which mounts the external handler fixtures.)
Source: spec §14 Phase 4 ("Document the final author-facing surface in
docs/guide/"). Remember the guide docs under docs/guide/ are generated from
docs/_pipeline/templates/*.md.dt via mur docs compile — edit the templates.
- Promote
docs/guide/extensibility-preview.mdfrom "provisional" to the stable author guide (or rename toextensibility.md): drop the breaking-change warning, document V1 as the default/only path, remove the "enabling the V1 path / off by default" section. (Done — filename kept (preserves the 9 inbound spec/task links); H1 retitled, provisional/[Experimental]/REACTOR_V1_PREVIEW/flag banner replaced with a stable intro + a "Dispatch order" section (V1 registry →_typeRegistry→ composition-primitive switch, no legacy fallthrough). Corrected the pool-reset enumeration (ControlEventStatePRESERVED across rent/return, not cleared — #114) and the per-control event-state section (EventHandlerStatesplit intoModifierEventHandlerState+ControlEventStateBox— done, not deferred); rewroteWriteSuppressedto the §8.3 hybrid. Commit60d0588c.) - Document the final authoring decision tree (§6.1.1): descriptor
.OneWay/.Controlled/.HandCodedControlled/.HandCodedEvent/ the engine shapes (.Imperative/.ImperativeBridged/.OneWayBridged/.CollectionDiffControlled) vs. hand-codedIElementHandler<,>; the children strategies (SingleContent/Panel/NamedSlots/ItemsHost/TemplatedItems(Erased)/TreeChildren/TabItemsHost/PreMountedItems/Imperative); the pool policy (§13 Q18); echo handling via tolerance/coercion metadata (post-§4.2). (Done — added a "Choosing an authoring shape (decision tree)" section covering the descriptor prop/engine shapes vs hand-codedIElementHandler<,>, the final 10-strategy children picker, and brief echo (.Controlled/valueDiffEchovsWriteSuppressed, per the §8.3 hybrid) + pool-policy notes. Commit60d0588c.) - If any edits touch generated guide pages, edit the
.md.dttemplates and re-runmur docs compile; verify the compiled output matches. (N/A — the edited pageextensibility-preview.mdis hand-maintained (no.md.dttemplate exists for it) andmuris not available in this x64 env. No generated guide page was touched, so no recompile was needed.) - Update
AGENTS.mdfor the post-Phase-4 reality: the "Adding a new WinUI control requires four touch points" section (the Element-record + Mount/Update-switch instructions describe the deleted legacy path — replace with the V1 descriptor model as the primary path), the "Echo suppression for value controls" section (ChangeEchoSuppressoris deleted — describe the per-control tolerance/coercion metadata +WriteSuppressed), and any event-state / per-element-state conventions that referenced the monolithicEventHandlerState(nowModifierEventHandlerState+ per-controlControlEventStateBox). Sweep for any other stale guidance pointing at the removed machinery. (Done — rewrote the "Adding a new WinUI control" section to the V1 descriptor path (Element →ControlDescriptor/IElementHandler→RegisterV1BuiltInHandlers→ selftest); rewrote "Echo suppression for value controls" to the §8.3 hybrid (NOTE:ChangeEchoSuppressoris RETAINED, not deleted — corrected the task's premise to match the settled hybrid); updated the per-element-state line toModifierEventHandlerState+ControlEventStateBox. The source-layout bullets namingMountXxx/UpdateXxxpartials are left intact — those internal helpers still exist and V1 handlers delegate into them; the bullets make no authoring claim. Commit60d0588c.)
Source: spec §15.6 / §15.7 Phase 4 row, Phase 1 deferrals 1.17 / 1.18 / 1.19, and the still-pending ARM64 stable-AC ratification gate (§14 Phase 3 finish).
🟡 STATUS: INDICATIVE ARM64 CAPTURE LANDED (alloc-only); FULL RATIFICATION STILL PENDING. A post-Phase-4 micro capture (M1–M13) ran on the baseline box
LAPTOP-4MEP83VI(ARM64-native, Release, .NET 10.0.8, reps=5, baseline-matched iters) and is committed underdocs/specs/047/phase4-results/LAPTOP-4MEP83VI/2026-05-29-arm64/(PR #465). The allocation axis is valid (deterministic —Directalloc matches the 2026-05-25 baseline byte-for-byte) and is ratified in the boxes below. The timing axis is NOT valid: §15.5 isolation (AC / High-Perf / DRR-off / foreground) was not enforced, soDirectns inflated +60–140% vs baseline; and the §4.9-mandated randomized/interleaved ordering + CPU-clock telemetry is not wired. The macro suite (L1–L14) is unrunnable — its projects (StressPerf.ReactorV2,BlankReactorV2) were deleted in Phase 4. A full sign-off still needs an isolated stable-AC re-capture + the macro suite rebuilt against the singleReactorvariant. Headline alloc result: most benches held/improved vs baseline (M9 −41%), but M1 regressed +20% (3.2× over its 407 B gate) and M12 +17% — confirming the KD-3 M1-over-budget trigger.Every bullet below is a measurement/ratification on the Phase 0/2 baseline machine
LAPTOP-4MEP83VI(ARM64-native, Release, stable-AC) per the §15.5 runbook; results commit underdocs/specs/047/phase4-results/LAPTOP-4MEP83VI/. The boxes stay unchecked until that capture lands. All code these gates measure is already in place: the §11.6 byte-gate TARGET constants (PerformanceBudgets.cs, §4.4), the single-Reactor-variant perf-project consolidation (§4.6), theModifierEventHandlerState/per-controlControlEventStateBoxsplit (§4.3), the bucketedElementbase (§4.4), and the AOT-clean external-assembly proof (PublishTrimmed+IsAotCompatible, 0 trim/AOT warnings) + the CI AOT selftest job (§4.7). No speculative perf-tuning was applied — the KD-3 "fold the M1 leading-ifbinder check into the pattern-switchcasearm" is explicitly measurement-gated ("if M1 is still above budget after §4.3/§4.4"), and the Phase-3 note already found related micro-opts net-negative (M4/M5), so it must not be done blind. Runbook for the baseline operator: run §15.3 M1–M13 with randomized/interleaved variant ordering + cooldowns + CPU-clock telemetry, refresh L2/L3/L4/L6 macros and L13/L14 (AOT, mixed ≥50%-external tree), check all §15.6 budget classes vs theReactorTodayhistorical baseline, then confirm/close KD-3 and (only if M1 is over budget) apply the binder-check fold and re-measure.
- ARM64 stable-AC ratification capture. Run the §15.3 micro suite
(M1–M13) on
LAPTOP-4MEP83VIARM64-native, Release, with randomized / interleaved variant ordering, cooldowns, and CPU-clock telemetry to defeat the thermal drift that made the prior attempt inconclusive. Commit underdocs/specs/047/phase4-results/LAPTOP-4MEP83VI/. Must clear the §13 Q1 thresholds and the §15.6 budgets. (PARTIAL — PR #465 landed a2026-05-29-arm64/capture, but WITHOUT the randomized/interleaved ordering + CPU-clock telemetry and WITHOUT §15.5 stable-AC isolation, so it does NOT yet satisfy this box. The allocation portion is valid and analyzed (RESULTS.md); the timing portion is throttled and must be re-captured under isolation. Box stays open.) - 1.17 — AOT publish + L13 / L14. AOT publish the split-library scenario
with
PublishTrimmed=true+IsAotCompatible=true; zero new trim/AOT warnings. L13 (mixed-tree, ≥50% external-assembly element types ≤ +10% vs all-in-core) and L14 (same scenario, AOT binary) pass. - 1.18 — macro suite catch-up. Ship/refresh the L2 / L3 / L4 / L6
scenarios on the (now single) production
Reactorvariant and capture on the baseline machine(s). - §15.6 regression budgets — final pass. All metric classes within
budget vs. the
ReactorTodayhistorical baseline: per-element alloc (M1–M3, must improve/equal), dispatch (M4–M6 ±10%), update (M7 ±5% / M8 ≤+10%), TTFF (L1–L3 ≤+5%), working set (L4 ≤+2% / L5 ≤+5%), FPS (L6–L8 p95 ≤105%), GC pauses (L9 ≤ baseline), heap stability (L11 ±10%). (PARTIAL — PR #465, alloc only. per-element alloc (M1–M3): M2 −5.1%, M3 −6.0% PASS; M1 +20.3% FAIL. Dispatch (M4–M6) and update (M7/M8) are TIMING classes → not ratifiable from the throttled capture. TTFF / working-set / FPS / GC / heap (L-series) → macro suite unrunnable post-Phase-4. Box stays open pending the isolated re-capture + the M1 alloc fix.) - Confirm KD-3 (dispatch fast-path for ported built-ins) stays closed at the
full registration scope (advisory showed M4/M5 net negative — wins from a
fatter handler table). Fold the M1 leading-
ifbinder check into the pattern-switchcasearm (the Phase 3-finish note flagged this as the Phase 4 perf-tuning item) if M1 is still above budget after §4.3 / §4.4. (TRIGGER NOW CONFIRMED — PR #465 measured M1 at 1,289 B/render, +20.3% over Today and 3.2× over the 407 B gate, i.e. M1 IS still above budget after §4.3/§4.4 (in fact it regressed). The M1 binder-check fold is therefore warranted; additionally investigate why the §4.4 bucketing made the leanest leaf HEAVIER (candidate sources: the addedElement.Extensionsslot, the §4.3 EHS split, theReactorState.PendingEchoMatchslot) and the M12 +17% pool-reuse regression. Apply + re-measure under isolation.)
- Phase 4 exit gate (top of file) items 1–8 all satisfied. (Code-satisfied
with two reconciliations + the baseline-machine carve: item 3 (delete
ChangeEchoSuppressor) is superseded by the §8.3 hybrid — the suppressor is intentionally retained alongside the value-diff arm;WriteSuppressedkeeps its public signature as required. Items 1/2/4/6 fully met. Items 5 (byte gates) and 7 (ARM64 ratification + AOT/macro) are code-complete but their measurement is ARM64-baseline-blocked (§4.9 handoff). Item 8: full x64 build + xunit + selftest green (§15.6 budget pass is part of the ARM64 capture). The exit gate's literal "ChangeEchoSuppressor deleted" / "byte gates pass" wording should be ratified against the settled hybrid + the baseline-machine carve by the spec author.) - Update the main tracker
(
047-extensible-control-model-implementation.md) and spec §14 status line to "Phase 4 complete — migration closed; V1 is the production path." (Done — added a Phase 4 status block to the main tracker header and to spec §14 "Phase 4 — cleanup"; both state code-complete / migration closed / V1 is the unconditional production path, with the ARM64 perf ratification + §11.6 byte-gate measurement called out as the only outstanding baseline-machine items, and theChangeEchoSuppressorbullet reconciled to the §8.3 hybrid.) - CI green: unit tests + selftests + full solution build (the standard PR
gate) on
windows-latest, .NET 10. (Validated locally on this x64 dev machine: full solution build (Reactor.slnx -p:Platform=x64) 0 err; full xunit 9128 pass / 0 fail; full selftest 0 failures (docking float/A11y/ Composition fixtures are intermittently flaky under full-suite load but pass deterministically when filtered — pre-existing, not a regression). Thewindows-latestCI run is the standard PR gate and runs on push.) - Final dead-code sweep: no
UseV1Protocol,REACTOR_USE_V1_PROTOCOL,ReactorV2,registerBuiltinHandlers, orEventHandlerState(monolith) references remain. (Done — grep-clean acrosssrcandtests: the only hits are historical doc comments describing the removals (e.g.Reconciler.cs:250"theUseV1Protocolflag … were removed", and test comments describing the completedEventHandlerState→ModifierEventHandlerStatesplit).ChangeEchoSuppressoris intentionally RETAINED per the §8.3 hybrid — removed from this sweep list; it is the chosen end state, not dead code.)
These two items are marked "future / deferred" in the spec and are not required to finish the V1 migration. Phase 4 only guarantees both are unblocked. (Scope was raised with the requester; proceeding on the documented spec defaults while awaiting any override.)
-
Source generation (§7). Spec §7 status + §13 Q1 reopen condition: source- gen is deferred with no committed timeline, gated on external triggers (WinUI→Reactor cycle-time pain, a new AOT-strict target, or compile-time descriptor validation need). It is a constant-factor perf enhancement on top of the hand-coded/descriptor model that Phase 2 already ratified; it changes no §13 decision and is not needed for V1 parity, cleanup, or the byte gates (the §11.6 hard gates are met by §9 split + bucketed base + echo elimination without it). Decision: keep deferred. When a trigger fires it plugs into the descriptor shape (generator emits descriptors/payload classes from
[ReactorControl]attributes) and must match/beat the Phase-4 hand-coded numbers without regressing any settled §13 question. -
Physical
Reactor.Controls.*package split (§1.1). §1.1 is the motivation (the external path becomes the first-party path); the actual carving of ~half the catalog into separate packages is a large, independent packaging effort with its own versioning/release implications. Phase 4 makes it unblocked — the public surface is locked and stable (§4.7), KD-4 closes the external typed-event gap (§4.7), and L13/L14 prove a separate assembly can author controls with noInternalsVisibleTounder trim/AOT (§4.9). Decision: follow-up effort. No correctness or parity work in the migration depends on executing the split.
- KD-1 (
OnCustomEventdrainsChangeEchoSuppressor) — migrated in §4.2. - KD-3 (dispatch fast-path for ported built-ins) — materially closed at registration scale (M4/M5 net negative); §4.9 confirms and folds the residual M1 binder-check cost into the pattern switch.
- KD-4 (public typed-event surface for external authors) — closed in §4.7.