@neo-opus-grace's defect-note (A2A, 2026-09-12 10:31Z, first occurrence): closing a dock pane throws TypeError: Cannot read properties of null (reading 'then') at src/dashboard/dock/Workspace.mjs:1710, reproduced on clean origin/dev; the pane closes, focus never moves to the sibling tab, and an App Worker error is logged. Per ticket-create §1e she did not file — capture is not admission. This ticket is the independent second occurrence, measured in the unit runtime on a real Group, and it adds a second facet the note could not see.
Amendment 2026-09-12 14:10 CEST (author): the original prescription below — defer the follow-up behind the returned promise on both branches — regressed three pre-existing direct-branch specs, measured by @neo-opus-grace as mutation M2 on PR #18602. The Fix now records the discriminating shape that PR ships; the fourth trap is added under Avoided Traps. Everything else stands as filed.
The Problem
Two facets, one mechanism — observed, not inferred:
The throw. On a workspace whose first shell was projected statically (the Workstation idiom, apps/workstation/view/Workspace.mjs:517), refreshPromise is null until the first commit projects. The first close throws synchronously at the .then. The write was already enqueued before the throw, so the close lands; focusDockCloseTarget never runs.
The stale chain. After any earlier commit, the follow-up chains onto the previous refresh, which is already settled, so focus runs before the close's own projection has reconciled the shell — the target it focuses is the outgoing chrome. The projection then replaces the handler's chained promise, so the "settled surface" the comment at :1683 promises ("chained onto refreshPromise, so it cannot reach chrome the reconciler retires") does not hold on the Group branch.
The Architectural Reality
handleDockCloseAction (Workspace.mjs:1690–1714) calls onDockZoneDocumentChange(result.document, descriptor, tabContainer) and then me.refreshPromise = me.refreshPromise.then(() => me.focusDockCloseTarget(...)).
onDockZoneDocumentChange (:2299–2315) has two branches. The direct branch runs projectDockZoneDocument, which assigns refreshPromise inside the call (:2371) — there the chain is correct and load-bearing: three specs close a pane and await workspace.refreshPromise at once to observe focus, so the direct branch's synchronous chain is documented contract. The Group branch returns set.commit(...) / set.write(...) and schedules no projection.
A Group commit's projection is scheduled by Commit.complete (src/manager/transaction/Commit.mjs:191–198) as a detached Promise.resolve().then(project) microtask after Commit.run; Transaction.write (src/manager/Transaction.mjs:725–726) resolves right after calling complete. So at the handler's .then, refreshPromise still holds the previous refresh, or null.
Every sibling consumer guards: me.refreshPromise?.catch(() => {}) || Promise.resolve() at :2080 and :2363; TourController wraps with Promise.resolve(workspace.refreshPromise). #18541 (closed) fixed the same null-ness on the test-fixture side; this is the production consumer.
The line arrived with the vocabulary cut #17841, moving #17419's close routing.
Reproduction — measured at dev@28e56e1543
Unit runtime; a real Group (Transaction.bind + WorkspaceSet.register) on a real Workspace subclass; the shell projected statically in onAfterConstructed; the real projected tabs node passed as tabContainer.
arm
result
1 · statically projected shell, first close
throws Cannot read properties of null (reading 'then'); after the Group queue settles the item has left the tree; focusDockCloseTarget calls: 0
2 · after one earlier Group commit
no throw; focusDockCloseTarget runs while the close's projection count is still 1 of 2 — before its own projection; the projection then replaces the handler's chained promise
3 · control — unregistered workspace, direct branch
no throw; focus runs after its own projection
4 · the fix shape
after awaiting the promise onDockZoneDocumentChange returns, Commit.complete's projection microtask has already run: projection count 2, refreshPromise is the close's own
The four arms live in the author's untracked scratch spec (DockCloseFollowUp.scratch.spec.mjs, 4/4) and are the seed for AC-4.
The Fix
src/dashboard/dock/Workspace.mjs, handleDockCloseAction: chain the follow-up onto the refresh this close scheduled, and the two branches publish that refresh at different times — so discriminate on the observable fact: did the call publish a new refresh before returning?
const published = me.refreshPromise,
pending = me.onDockZoneDocumentChange(result.document, descriptor, tabContainer),
focus = () => me.focusDockCloseTarget({dockNodeId: modelNodeId, itemId: focusId});
if (me.refreshPromise && me.refreshPromise !== published) {
// direct branch: the call projected and assigned its own refresh — chain now, which keeps the// follow-up observable to a caller that awaits `refreshPromise` immediately (the close specs do)
me.refreshPromise = me.refreshPromise.then(focus)
} else {
// Group branch: the write resolved before Commit.complete's projection microtask ran (arm 4);// the assignment stays inside the continuation, or the projection waits on itselfPromise.resolve(pending)
.then(() => {me.refreshPromise = (me.refreshPromise ?? Promise.resolve()).then(focus)})
.catch(() => {})
}
The originally prescribed uniform defer (Promise.resolve(pending).then(...) on both branches) is wrong on the direct branch: it registers focus one microtask after a caller's immediate await workspace.refreshPromise, so the caller observes no focus — three shipped specs fail. Refusal parity: a rejected write means no close landed, so the chain runs no focus, and its rejection exposure must match today's (no new unhandled rejection). Docblocks: refreshPromise (:428–433) states that the Group branch does not update it inside onDockZoneDocumentChange; the :1683 comment is corrected to the new shape.
Acceptance Criteria
AC-1 (facet 1): on a Group-registered workspace with a statically projected shell and refreshPromise === null, a close throws nothing, the item leaves the tree, and focusDockCloseTarget runs once, after the close's own projection.
AC-2 (facet 2): after an earlier Group commit, the follow-up runs after the close's own projection (projection count 2 at focus time), and refreshPromise afterwards includes the follow-up, so later commits schedule off it.
AC-3 (control): the direct branch is unchanged — no throw, focus after its own refresh, and a caller awaiting refreshPromise immediately after the close still observes the focus (the three existing close specs stay green).
AC-4 (regression spec): the four arms land as a unit spec under test/playwright/unit/dashboard/ using a real Transaction Group and WorkspaceSet, never a stubbed branch.
AC-5: a refused Group write runs no focus and surfaces exactly as a rejected projection does today — no new unhandled rejection.
AC-6 (docs): the two docblock corrections above.
Post-merge, flagged: closing a tab in the Workstation logs no App Worker error. The unit tier cannot assert the worker console (#18554), so this is a headed or Neural Link receipt on the flagship.
Out of Scope
The pending-versus-refreshPromise design question on D#18594 (OQ3). This ticket fixes the shipped consumer of that confusion, not the contract.
The Workstation's static-projection idiom.
#18541's fixture side (closed).
Avoided Traps
A ?. guard alone fixes the throw and keeps facet 2 — arm 2 stays red (PR #18602's mutation M1: the throw arm and the ordering arm both red).
Assigning the chain to refreshPromise before the projection is scheduled:projectDockZoneDocument takes me.refreshPromise?.catch(...) as its tail (:2363), so the tail would wait on a chain that waits on the refresh — a deadlock on the Group branch.
Awaiting the write inside the handler changes the handler's synchronous {document, errors} return, which the action router relies on.
Deferring uniformly on both branches — this ticket's original prescription — regresses the direct branch's ordering contract: the opt-in close action … focuses after refresh, a model-ahead close targets live chrome but focuses the model-selected successor, and closing a node's only item focuses the surviving DockWorkspace root … all await workspace.refreshPromise right after the close (PR #18602's mutation M2: four arms red, disjoint from M1's two). Hence the discriminator.
Related
@neo-opus-grace's defect-note (first occurrence) · PR #18602 (the fix, with the mutation matrix) · #18541 · #17419 · #17841 · #18554 · D#18594 OQ3.
unowned-rationale: superseded — taken by @neo-opus-grace (first refusal) as PR #18602.
Six-stage chain: premise reproduced independently (arms 1–3) · prescription verified by arm 4, then amended on the direct-branch contract (M2) · substrate = the engine Workspace, the consumer of the Group write's timing · consumers = every Group-managed dock host (Workstation, Demo B) · service boundary: none · Decision Record impact: none.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-12 11:38Z; no equivalent found. A2A claim sweep: list_messages({status: 'all', limit: 30}) at 11:27Z; no [lane-claim] on this scope; the only prior mention is the defect-note. Memory Core sweep: query_raw_memories on the symptom — nothing decided.
Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c
Retrieval Hint: "close focus follow-up chains onto the previous refreshPromise under a Group"
🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c
Context
@neo-opus-grace's defect-note (A2A, 2026-09-12 10:31Z, first occurrence): closing a dock pane throws
TypeError: Cannot read properties of null (reading 'then')atsrc/dashboard/dock/Workspace.mjs:1710, reproduced on cleanorigin/dev; the pane closes, focus never moves to the sibling tab, and an App Worker error is logged. Perticket-create§1e she did not file — capture is not admission. This ticket is the independent second occurrence, measured in the unit runtime on a real Group, and it adds a second facet the note could not see.The Problem
Two facets, one mechanism — observed, not inferred:
apps/workstation/view/Workspace.mjs:517),refreshPromiseisnulluntil the first commit projects. The first close throws synchronously at the.then. The write was already enqueued before the throw, so the close lands;focusDockCloseTargetnever runs.:1683promises ("chained ontorefreshPromise, so it cannot reach chrome the reconciler retires") does not hold on the Group branch.The Architectural Reality
handleDockCloseAction(Workspace.mjs:1690–1714) callsonDockZoneDocumentChange(result.document, descriptor, tabContainer)and thenme.refreshPromise = me.refreshPromise.then(() => me.focusDockCloseTarget(...)).onDockZoneDocumentChange(:2299–2315) has two branches. The direct branch runsprojectDockZoneDocument, which assignsrefreshPromiseinside the call (:2371) — there the chain is correct and load-bearing: three specs close a pane andawait workspace.refreshPromiseat once to observe focus, so the direct branch's synchronous chain is documented contract. The Group branch returnsset.commit(...)/set.write(...)and schedules no projection.Commit.complete(src/manager/transaction/Commit.mjs:191–198) as a detachedPromise.resolve().then(project)microtask afterCommit.run;Transaction.write(src/manager/Transaction.mjs:725–726) resolves right after callingcomplete. So at the handler's.then,refreshPromisestill holds the previous refresh, ornull.me.refreshPromise?.catch(() => {}) || Promise.resolve()at:2080and:2363;TourControllerwraps withPromise.resolve(workspace.refreshPromise).#18541(closed) fixed the same null-ness on the test-fixture side; this is the production consumer.#17841, moving#17419's close routing.Reproduction — measured at
dev@28e56e1543Unit runtime; a real Group (
Transaction.bind+WorkspaceSet.register) on a realWorkspacesubclass; the shell projected statically inonAfterConstructed; the real projected tabs node passed astabContainer.Cannot read properties of null (reading 'then'); after the Group queue settles the item has left the tree;focusDockCloseTargetcalls: 0focusDockCloseTargetruns while the close's projection count is still 1 of 2 — before its own projection; the projection then replaces the handler's chained promiseonDockZoneDocumentChangereturns,Commit.complete's projection microtask has already run: projection count 2,refreshPromiseis the close's ownThe four arms live in the author's untracked scratch spec (
DockCloseFollowUp.scratch.spec.mjs, 4/4) and are the seed for AC-4.The Fix
src/dashboard/dock/Workspace.mjs,handleDockCloseAction: chain the follow-up onto the refresh this close scheduled, and the two branches publish that refresh at different times — so discriminate on the observable fact: did the call publish a new refresh before returning?const published = me.refreshPromise, pending = me.onDockZoneDocumentChange(result.document, descriptor, tabContainer), focus = () => me.focusDockCloseTarget({dockNodeId: modelNodeId, itemId: focusId}); if (me.refreshPromise && me.refreshPromise !== published) { // direct branch: the call projected and assigned its own refresh — chain now, which keeps the // follow-up observable to a caller that awaits `refreshPromise` immediately (the close specs do) me.refreshPromise = me.refreshPromise.then(focus) } else { // Group branch: the write resolved before Commit.complete's projection microtask ran (arm 4); // the assignment stays inside the continuation, or the projection waits on itself Promise.resolve(pending) .then(() => {me.refreshPromise = (me.refreshPromise ?? Promise.resolve()).then(focus)}) .catch(() => {}) }The originally prescribed uniform defer (
Promise.resolve(pending).then(...)on both branches) is wrong on the direct branch: it registers focus one microtask after a caller's immediateawait workspace.refreshPromise, so the caller observes no focus — three shipped specs fail. Refusal parity: a rejected write means no close landed, so the chain runs no focus, and its rejection exposure must match today's (no new unhandled rejection). Docblocks:refreshPromise(:428–433) states that the Group branch does not update it insideonDockZoneDocumentChange; the:1683comment is corrected to the new shape.Acceptance Criteria
refreshPromise === null, a close throws nothing, the item leaves the tree, andfocusDockCloseTargetruns once, after the close's own projection.refreshPromiseafterwards includes the follow-up, so later commits schedule off it.refreshPromiseimmediately after the close still observes the focus (the three existing close specs stay green).test/playwright/unit/dashboard/using a realTransactionGroup andWorkspaceSet, never a stubbed branch.#18554), so this is a headed or Neural Link receipt on the flagship.Out of Scope
pending-versus-refreshPromisedesign question onD#18594(OQ3). This ticket fixes the shipped consumer of that confusion, not the contract.#18541's fixture side (closed).Avoided Traps
?.guard alone fixes the throw and keeps facet 2 — arm 2 stays red (PR #18602's mutation M1: the throw arm and the ordering arm both red).refreshPromisebefore the projection is scheduled:projectDockZoneDocumenttakesme.refreshPromise?.catch(...)as its tail (:2363), so the tail would wait on a chain that waits on the refresh — a deadlock on the Group branch.{document, errors}return, which the action router relies on.the opt-in close action … focuses after refresh,a model-ahead close targets live chrome but focuses the model-selected successor, andclosing a node's only item focuses the surviving DockWorkspace root …allawait workspace.refreshPromiseright after the close (PR #18602's mutation M2: four arms red, disjoint from M1's two). Hence the discriminator.Related
@neo-opus-grace's defect-note (first occurrence) · PR #18602 (the fix, with the mutation matrix) ·
#18541·#17419·#17841·#18554·D#18594OQ3.unowned-rationale:superseded — taken by @neo-opus-grace (first refusal) as PR #18602.Six-stage chain: premise reproduced independently (arms 1–3) · prescription verified by arm 4, then amended on the direct-branch contract (M2) · substrate = the engine Workspace, the consumer of the Group write's timing · consumers = every Group-managed dock host (Workstation, Demo B) · service boundary: none · Decision Record impact: none.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-12 11:38Z; no equivalent found. A2A claim sweep:
list_messages({status: 'all', limit: 30})at 11:27Z; no[lane-claim]on this scope; the only prior mention is the defect-note. Memory Core sweep:query_raw_memorieson the symptom — nothing decided.Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c Retrieval Hint: "close focus follow-up chains onto the previous refreshPromise under a Group"
🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c