Context
Split out of #18448, where it was AC-4. That AC has been retired there rather than carried: it named its own mechanism ("under candidate 2 this is structural — the transaction throws") and explicitly excluded instrumentation, so it was a rider on a candidate that is now withdrawn. What survives is broader than the pin action and predates #18448's defect entirely.
Found from two directions in one hour: @neo-gpt-emmy's exact-head probe showed a step-2 reduction failure returning {errors} with 0 publications and a local early return; @neo-opus-ada found that awaiting a rejected commit turns the same seam into a potential unhandled rejection. Both are the same wire.
The Problem
src/dashboard/dock/projection/LayoutAdapter.mjs:1344 wires the header-action intent as a listener slot:
headerAction: data => context.onDockHeaderAction?.({...data, dockNodeId: nodeId}),The arrow has an expression body, so it returns the handler's value — but the slot that invokes it is a component listener, and nothing consumes what a listener returns. So for every dock header action, not only pin:
- a handler that returns
{document, errors} reports its refusal to nobody — nothing throws, nothing logs, the projection simply does not change;
- a handler that returns a rejected promise produces an unhandled rejection, because there is no caller to attach a rejection handler.
The second half is latent today and becomes reachable the moment any handleDock*Action is awaited. handleDockPopOutAction (Workspace.mjs:1667) and handleDockReloadAction (:1874) are already async on this exact path.
This is how #18448 stayed invisible. Its report opens "Zero console output across the whole cycle" — a pin that silently no-ops for a whole pin → reveal → restore → pin sequence. The silence was not incidental to that bug; it is this seam, and it will hide the next one the same way.
The Architectural Reality
The listener contract is not the thing to change: the comment directly above line 1344 records a deliberate decision — "Every action intent is forwarded, not just close… The owner decides what it handles" — and that filtering-belongs-to-the-owner call is correct and should stand.
So the reporting boundary belongs below the wire, at onDockHeaderAction (Workspace.mjs:1526) or inside each handleDock*Action, because nothing above can observe either failure channel.
Note ADR 0029 §2.7 settles the user-facing half for the collapse row — "the intermediate state (unpinned, visible) is benign and acceptable on step-2 failure" — and this ticket does not reopen that. An outcome being acceptable to the user is not the same as the failure being undiagnosable to a developer; this ticket is only about the second.
Acceptance Criteria
Out of Scope
- #18448's stale-document defect and its await-publication fix — delivered there.
- Changing the forward-every-intent listener contract, which is a recorded decision.
- Any user-facing error surface: this is a developer-diagnosability ticket, and a toast or banner would be a separate product decision.
Related
- #18448 — origin; AC-4 retired there with this as its successor
- #18304 — the dock ownership arc
- ADR 0029 §2.7 — the benign-intermediate-state row this deliberately does not touch
Duplicate sweep at creation: all-state search over header-action / silent-refusal terms returned #17876, #18306, #17945, #17969, #18149, #17990 — all CLOSED and none covering this wire.
Context
Split out of #18448, where it was AC-4. That AC has been retired there rather than carried: it named its own mechanism ("under candidate 2 this is structural — the transaction
throws") and explicitly excluded instrumentation, so it was a rider on a candidate that is now withdrawn. What survives is broader than the pin action and predates #18448's defect entirely.Found from two directions in one hour: @neo-gpt-emmy's exact-head probe showed a step-2 reduction failure returning
{errors}with 0 publications and a local early return; @neo-opus-ada found that awaiting a rejected commit turns the same seam into a potential unhandled rejection. Both are the same wire.The Problem
src/dashboard/dock/projection/LayoutAdapter.mjs:1344wires the header-action intent as a listener slot:headerAction: data => context.onDockHeaderAction?.({...data, dockNodeId: nodeId}),The arrow has an expression body, so it returns the handler's value — but the slot that invokes it is a component listener, and nothing consumes what a listener returns. So for every dock header action, not only pin:
{document, errors}reports its refusal to nobody — nothing throws, nothing logs, the projection simply does not change;The second half is latent today and becomes reachable the moment any
handleDock*Actionis awaited.handleDockPopOutAction(Workspace.mjs:1667) andhandleDockReloadAction(:1874) are alreadyasyncon this exact path.This is how #18448 stayed invisible. Its report opens "Zero console output across the whole cycle" — a pin that silently no-ops for a whole pin → reveal → restore → pin sequence. The silence was not incidental to that bug; it is this seam, and it will hide the next one the same way.
The Architectural Reality
The listener contract is not the thing to change: the comment directly above line 1344 records a deliberate decision — "Every action intent is forwarded, not just
close… The owner decides what it handles" — and that filtering-belongs-to-the-owner call is correct and should stand.So the reporting boundary belongs below the wire, at
onDockHeaderAction(Workspace.mjs:1526) or inside eachhandleDock*Action, because nothing above can observe either failure channel.Note ADR 0029 §2.7 settles the user-facing half for the collapse row — "the intermediate state (unpinned, visible) is benign and acceptable on step-2 failure" — and this ticket does not reopen that. An outcome being acceptable to the user is not the same as the failure being undiagnosable to a developer; this ticket is only about the second.
Acceptance Criteria
handleDock*Actionis observable without a debugger — the channel is chosen and recorded, not assumed.handleDock*Actionpromise cannot become an unhandled rejection, for the already-asyncactions as well as any made async later.devand passes after the fix; the arm asserts the observable, not the internal call.LayoutAdaptercomment at the wire says where reporting lives, since that is where someone will first look for it.Out of Scope
Related
Duplicate sweep at creation: all-state search over header-action / silent-refusal terms returned #17876, #18306, #17945, #17969, #18149, #17990 — all CLOSED and none covering this wire.