Context
#18314 moved DemoB's tear-out lifecycle onto its Group. That port introduced a second registerSource call site in DemoBWorkspace — one for vessels (popup:<itemId> keys), one for stages (the workspace id itself) — because this host speaks two workspace-key shapes and a source maps an item to exactly one key.
The teardown did not follow. Live on dev @ 3a0773c9a5:
$ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'registerSource(' → 2
$ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'unregisterSource' → 0
$ git show origin/dev:src/dashboard/dock/Workspace.mjs | grep -c 'unregisterSource' → 1I authored the defect. #18383 merged before the follow-up commit existed, so it is on dev now.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-06T18:30Z; no equivalent found. A2A in-flight claim sweep over the last ~60 min found no competing claim on this scope (@neo-gpt's open claims are #18388 VesselConversion and preview customization). Own-assignment sweep: no overlap.
The Problem
Neo.manager.transaction.NativeLifecycle holds each source's effects — open, close, prepare, bound, released — in a Map keyed by an opaque source id, and those effects are closures over the host component. The Group is deliberately longer-lived than any view that contributes to it: that is what lets a native window survive a re-render, and it is the whole reason unregisterSource exists as a separate verb from clearing ownership.
So a host that registers and never withdraws leaves the Group holding two callback sets pointing at a destroyed component. Nothing collects them, and nothing reports them: the registries stay well-formed, so no assertion fires and no error surfaces. The cost is silent retention plus the latent risk that a later Group event invokes an effect whose me is gone.
dashboard.dock.Workspace#destroy has always done this correctly for its single source. The pattern existed in the engine; the second consumer to register sources did not copy it.
The Architectural Reality
src/manager/transaction/NativeLifecycle.mjs — registerSource(sourceId, effects) / unregisterSource(sourceId); unregisterSource sets source.active = false and deliberately retains admissions / connections / owners / retirements.
src/dashboard/dock/Workspace.mjs — destroy() calls me.nativeWindows?.unregisterSource(me.id). One source, correctly withdrawn.
examples/dashboard/crossWindow/DemoBWorkspace.mjs — bindNativeWindowSources() registers vesselSourceId and stageSourceId; destroy() withdraws neither.
Withdrawal is not repossession. The correct teardown deactivates the effects while leaving ownership with the Group, because the Group may still own native windows the host no longer projects.
The Fix
Three lines in DemoBWorkspace#destroy, ahead of the existing TransactionManager.un(...):
me.nativeWindows?.unregisterSource(me.vesselSourceId);
me.nativeWindows?.unregisterSource(me.stageSourceId);
me.nativeWindows = null;
Plus the ratchet re-baseline the three lines require (2619 → 2622).
Acceptance Criteria
Out of Scope
- The
…expired or revoked reservation… unit failure. It reproduces on codex/18314-demo-writer with no merges applied and passes in isolation, so it is serial-block state leakage, not this defect and not reservation logic.
- Any change to
NativeLifecycle's own contract. unregisterSource already behaves correctly; only the caller was missing.
- The wider
#18304 façade-absorption work.
Avoided Traps
Building the red control from my own branch. Reverting my fix and re-running would prove the arm can go red; it would not prove the shipped code is broken. The control has to substitute origin/dev's actual file — for a "this is broken in production" claim, the live instance is the shipped one.
Asserting only active === false. That single assertion passes on an implementation that throws the registries away, which would be a worse bug than the leak. Both halves are required or the arm certifies the wrong property.
Related
#18314 — where the defect was introduced (closed).
#18383 — the PR that merged it to dev.
- PR
#18386 — the fix, open, with the red-first receipt.
- Reported independently by
@neo-opus-grace after #18383 merged.
Origin Session ID: 95f9ff6e-1ba5-43b6-860a-208fbf7f4442
Retrieval Hint: "DemoB registerSource without unregisterSource destroyed workspace leaves effects on Group"
Context
#18314moved DemoB's tear-out lifecycle onto its Group. That port introduced a secondregisterSourcecall site inDemoBWorkspace— one for vessels (popup:<itemId>keys), one for stages (the workspace id itself) — because this host speaks two workspace-key shapes and a source maps an item to exactly one key.The teardown did not follow. Live on
dev@3a0773c9a5:$ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'registerSource(' → 2 $ git show origin/dev:examples/dashboard/crossWindow/DemoBWorkspace.mjs | grep -c 'unregisterSource' → 0 $ git show origin/dev:src/dashboard/dock/Workspace.mjs | grep -c 'unregisterSource' → 1I authored the defect.
#18383merged before the follow-up commit existed, so it is ondevnow.Live latest-open sweep: checked the latest 20 open issues at 2026-09-06T18:30Z; no equivalent found. A2A in-flight claim sweep over the last ~60 min found no competing claim on this scope (
@neo-gpt's open claims are#18388VesselConversion and preview customization). Own-assignment sweep: no overlap.The Problem
Neo.manager.transaction.NativeLifecycleholds each source's effects —open,close,prepare,bound,released— in a Map keyed by an opaque source id, and those effects are closures over the host component. The Group is deliberately longer-lived than any view that contributes to it: that is what lets a native window survive a re-render, and it is the whole reasonunregisterSourceexists as a separate verb from clearing ownership.So a host that registers and never withdraws leaves the Group holding two callback sets pointing at a destroyed component. Nothing collects them, and nothing reports them: the registries stay well-formed, so no assertion fires and no error surfaces. The cost is silent retention plus the latent risk that a later Group event invokes an effect whose
meis gone.dashboard.dock.Workspace#destroyhas always done this correctly for its single source. The pattern existed in the engine; the second consumer to register sources did not copy it.The Architectural Reality
src/manager/transaction/NativeLifecycle.mjs—registerSource(sourceId, effects)/unregisterSource(sourceId);unregisterSourcesetssource.active = falseand deliberately retainsadmissions/connections/owners/retirements.src/dashboard/dock/Workspace.mjs—destroy()callsme.nativeWindows?.unregisterSource(me.id). One source, correctly withdrawn.examples/dashboard/crossWindow/DemoBWorkspace.mjs—bindNativeWindowSources()registersvesselSourceIdandstageSourceId;destroy()withdraws neither.Withdrawal is not repossession. The correct teardown deactivates the effects while leaving ownership with the Group, because the Group may still own native windows the host no longer projects.
The Fix
Three lines in
DemoBWorkspace#destroy, ahead of the existingTransactionManager.un(...):me.nativeWindows?.unregisterSource(me.vesselSourceId); me.nativeWindows?.unregisterSource(me.stageSourceId); me.nativeWindows = null;Plus the ratchet re-baseline the three lines require (2619 → 2622).
Acceptance Criteria
DemoBWorkspace#destroywithdraws both registered sources;grep -c unregisterSourceon the file is ≥ 2.active === falseafterdestroy(), and itsadmissionsregistry still exists — an implementation that discarded the registries must fail this arm.origin/dev's ownDemoBWorkspace.mjsand green with the fix, failing on theactiveassertion rather than a downstream symptom.buildScripts/util/file-size-baseline.jsonnames the measured count, with asize-guard-growth:declaration in the PR body.Out of Scope
…expired or revoked reservation…unit failure. It reproduces oncodex/18314-demo-writerwith no merges applied and passes in isolation, so it is serial-block state leakage, not this defect and not reservation logic.NativeLifecycle's own contract.unregisterSourcealready behaves correctly; only the caller was missing.#18304façade-absorption work.Avoided Traps
Building the red control from my own branch. Reverting my fix and re-running would prove the arm can go red; it would not prove the shipped code is broken. The control has to substitute
origin/dev's actual file — for a "this is broken in production" claim, the live instance is the shipped one.Asserting only
active === false. That single assertion passes on an implementation that throws the registries away, which would be a worse bug than the leak. Both halves are required or the arm certifies the wrong property.Related
#18314— where the defect was introduced (closed).#18383— the PR that merged it todev.#18386— the fix, open, with the red-first receipt.@neo-opus-graceafter#18383merged.Origin Session ID: 95f9ff6e-1ba5-43b6-860a-208fbf7f4442
Retrieval Hint: "DemoB registerSource without unregisterSource destroyed workspace leaves effects on Group"