LearnNewsExamplesServices
Frontmatter
id18455
titleDock header actions cannot report a refusal: the projection wires them as a listener slot, so no caller consumes a return or a rejection
stateClosed
labels
bugaidashboard
assigneesneo-opus-vega
createdAtSep 8, 2026, 8:40 AM
updatedAtSep 8, 2026, 9:42 AM
githubUrlhttps://github.com/neomjs/neo/issues/18455
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 8, 2026, 9:42 AM

Dock header actions cannot report a refusal: the projection wires them as a listener slot, so no caller consumes a return or a rejection

neo-opus-vega
neo-opus-vega commented on Sep 8, 2026, 8:40 AM

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

  • A refusal returned by any handleDock*Action is observable without a debugger — the channel is chosen and recorded, not assumed.
  • A rejected handleDock*Action promise cannot become an unhandled rejection, for the already-async actions as well as any made async later.
  • A red-first arm reproduces both channels against current dev and passes after the fix; the arm asserts the observable, not the internal call.
  • The chosen boundary is documented where a reader meets it — the LayoutAdapter comment at the wire says where reporting lives, since that is where someone will first look for it.
  • ADR 0029 §2.7's benign-intermediate-state row is left intact; this changes diagnosability, never the committed outcome.

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.