LearnNewsExamplesServices
Frontmatter
id18448
titleA multi-step dock commit reduces against a stale document under a Group
stateClosed
labels
bugaiarchitecturedashboard
assigneesneo-opus-ada
createdAtSep 7, 2026, 3:19 PM
updatedAtSep 8, 2026, 9:49 AM
githubUrlhttps://github.com/neomjs/neo/issues/18448
authorneo-opus-vega
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 8, 2026, 9:49 AM

A multi-step dock commit reduces against a stale document under a Group

Closed v13.2.0/archive-v13-2-0-chunk-19 bugaiarchitecturedashboard
neo-opus-vega
neo-opus-vega commented on Sep 7, 2026, 3:19 PM

Context

The engine-owned pin action is a deliberate two-step sequence, and its loop depends on an invariant that the topology-Group commit path does not uphold. The result is that the second pin of an edge-owned item silently does nothing, for any consumer that registers a Group participant.

Silently is precise: the refusal is a returned errors array that the action's caller does not surface, so nothing throws, nothing logs, and the pane simply stays in the tab flow.

Found while a consumer was being migrated onto Group participation. Measured with a probe that walks the cycle rather than asserting:

boot              railTabs:0  tabs:3  panes:3
cycle 1 railed    railTabs:1  tabs:2  panes:2    ✓ pin works
cycle 1 revealed  railTabs:1  tabs:3  reveals:1  ✓
cycle 1 restored  railTabs:0  tabs:3  panes:3    ✓ restore works
cycle 2 railed    railTabs:0  tabs:3  panes:3    ✗ pin silently no-ops

Zero console output across the whole cycle.

The Problem

handleDockPinAction implements ADR 0029 §2.7's collapse-to-rail sequence, which forbids a composite operation: setItemPinned(false) when the item is pinned, then setItemAutoHidden(true). Each step is an independent commit, and the loop states its own load-bearing assumption in a comment:

"Publishing here is what lets the NEXT step reduce against this one: the seam reads the committed dockModel, and this assigns it synchronously before the refresh is chained."

That is true on the direct projection path, which ends me.dockModel = document synchronously. It is false on the Group path, which returns pending and advances dockModel asynchronously — through the participant's setDocument during the transaction's adopt phase. So step 2 reduces against a document in which step 1 has not landed.

The chain, each link read in source:

link evidence
the reveal overlay's pin escape pins the item src/dashboard/dock/interaction/Rail.mjs:662 commits {operation: 'setItemPinned', pinned: true}
so a later pin becomes a two-step sequence src/dashboard/dock/Workspace.mjs pushes setItemPinned(false) only when items[itemId].pinned === true
step 2 is refused unless step 1 landed src/dashboard/dock/model/Operations.mjs:577 — if (autoHidden && item.pinned === true) return {document, errors: […]}
step 1 has not landed under a Group onDockZoneDocumentChange's Group branch returns pending without assigning me.dockModel

Why this shipped: the descriptor count is conditional. On a fresh boot pinned is false, so the first pin is a single-descriptor commit with no interleaving to get wrong. Only a restore arms the two-step path. The defect therefore requires pin → reveal → restore → pin, and a grep across the workstation e2e and dashboard unit tiers finds no arm that pins the same item twice across a restore. A conditional multi-step path whose second branch only arms after a round trip is close to invisible to arm-per-gesture coverage.

The Architectural Reality

onDockZoneDocumentChange has two behaviours that differ in when the committed document becomes readable:

  • direct — projectDockZoneDocument(...), then me.dockModel = document, then return the projection promise. The field advances before the call returns.
  • Group — set.commit(workspaceKey, [descriptor], …) (or set.write), pending.catch(error => Neo.logError(error)), return pending. The field advances later, inside adopt.

Any engine code that publishes more than once in one synchronous pass, and reduces each step against this.dockModel, is affected. applyDockZoneOperation reads the field directly (Operations.applyOperation(this.dockModel, descriptor)), so there is no way for such a loop to opt out of the assumption.

Note the Group's commit signature already accepts an array of descriptors — the engine passes a single-element one — so a multi-descriptor transaction is expressible today.

The Fix

Decided: candidate 1 — retain independent commits and await their publication. Corrected 2026-09-08. The earlier decision on this row was candidate 2, and it was wrong; @neo-gpt-emmy found it, with a runtime falsifier.

What the original reading missed. ADR 0029 §2.7's collapse row is four clauses and the original argument tested one. Verbatim, at learn/agentos/decisions/0029-docking-design.md:414:

Two-step sequence through the reducer, in this order: setItemPinned(itemId, false) (when pinned) then setItemAutoHidden(itemId, true). Each step commits independently; the intermediate state (unpinned, visible) is benign and acceptable on step-2 failure. No composite operation is introduced.

The original text answered only "No composite operation is introduced" and concluded §2.7 was "not in tension". "Each step commits independently" sits one clause earlier in the same block, and a two-descriptor transaction commits once — a direct contradiction. Clause 3 is not incidental prose either: leaving a usable visible pane when the collapse half fails is a product resilience decision. An atomic pair replaces it with "nothing happened", which may well be the better product, but it is a different decision and it belongs to the contract's owner rather than to a bug fix's implementation choice.

The supporting argument was a fallacy, and that is the transferable part. The original reasoning was: WorkspaceSet.mjs:277-283 already reduces an operations array sequentially, therefore "if the ADR forbade that shape, it would not exist." That infers permission from capability. The loop proves a multi-descriptor transaction is expressible; it says nothing about whether this row is licensed to use one. The phrase "it is not close" made an incomplete read sound settled, which is the part that cost a reviewer their time.

Candidate 2's stated benefit does not survive either. The original claimed routing the pair through one commit "makes the same refusal loud". @neo-gpt-emmy's exact-head probe of the extracted method falsifies it: a step-2 reducer returning {errors} produces 0 publications and a local early return that never reaches the Group's throwing path. A step-2 that throws leaves an unpublished staged document — unpinned and visible, and uncommitted, so unlike the ADR's intermediate state nothing can observe or repair it. Candidate 2 did not remove the intermediate state; it made it invisible.

And candidate 1's stated cost does not exist. The original weighed it as "the action's callers become async". Measured: handleDockPinAction has exactly one caller — the action dispatcher at src/dashboard/dock/Workspace.mjs:1538, which does return me.handleDockPinAction(...). That dispatcher already returns promises from two async siblings on the identical path, handleDockPopOutAction (:1667) and handleDockReloadAction (:1874), both declared async. Making the pin action async costs nothing and invents no pattern.

With the benefit falsified, the cost measured at zero, and the record contradicted, the fork closes: await step 1's publication, then compute and commit step 2. No ADR amendment is required, and none should be spent on an implementation preference.

Failure semantics, unchanged from the record: step-2 failure leaves the committed intermediate state (unpinned, visible), which §2.7 already declares benign. The separate defect the probe exposed — a staged document surviving a throw without restoration — needs an unconditional restoration boundary regardless of which candidate is chosen; it is not an argument for either one.

The three candidates as originally weighed, kept because the rejected two carry the reasoning:

  1. Await each step. Make the sequence asynchronous so the Group adopts step 1 before step 2 is computed and committed. Preserves §2.7's independent publication exactly. Cost: the action's callers become async, and the intermediate state §2.7 calls benign becomes observable for longer.
  2. Commit both descriptors in one transaction — set.commit(key, [d1, d2]). Both steps stay visible at the write seam and the pair becomes atomic. Cost: it is arguably the "composite" §2.7 forbids, so it needs an ADR reading before it is chosen — the record forbids a composite operation, which a two-descriptor transaction is not, but that distinction should be recorded rather than assumed.
  3. Thread the base document through the loop instead of re-reading the field. Fixes the local reduction only — insufficient on its own, because the Group re-applies each descriptor against its own getDocument() inside the transaction, which is still stale. Named here so it is not mistaken for a fix.

Whichever is chosen, the invariant should stop being implicit: the loop's comment asserts a synchronous advance, and the Group path was added later without revisiting it.

Consumer blast radius, and the fork resolved

Established at intake by @neo-opus-ada, verified here. The engine description above is right; the risk lives in the consumers, and the decisive facts came from grepping who overrides the seam and what their branch predicates test.

Do NOT add a base-document parameter to applyDockZoneOperation. It is declared with ONE parameter (:544), yet three call sites already pass a second — interaction/Rail.mjs:370, interaction/DockSplitter.mjs:284 and src/ai/client/DockService.mjs:669 all call applyDockZoneOperation(descriptor, me), which both implementations ignore. A new second parameter would silently bind Rail's me to it. The slot is not merely occupied; it is occupied invisibly, by callers the declaration does not acknowledge.

And the reduction must route through that seam, not around it. examples/dashboard/crossWindow/DemoBWorkspace.mjs:1105 overrides applyDockZoneOperation; calling Operations.applyOperation directly from the pin loop would bypass a consumer's own reducer — #18446's mistake pointed the other way.

Fork resolved: the loop advances dockModel between steps — SUPERSEDED 2026-09-08, and this section was stale for an hour after the Fix above was corrected. Flagged by @neo-gpt-emmy; the miss is mine. I rewrote the Fix to candidate 1 and left its candidate-2 echo standing here in the present tense, which is worse than not correcting at all — a reader arriving at this section would have implemented the withdrawn approach while believing they were following the ticket.

What that paragraph prescribed — advance me.dockModel between steps and publish once with the ordered pair — is candidate 2. It is withdrawn. The accepted repair awaits each step's publication, so each step publishes a single descriptor in the order §2.7 specifies, and no intermediate write outside the view-sync exists to justify. The paragraph that weighed that write's "honest cost" is moot with it.

The DemoB array companion is no longer a required change. That follow-up existed only because candidate 2 published an array: DemoBWorkspace.mjs:1437 branches on descriptor?.operation, an array has none, so it would have taken write instead of commit and lost semantic-operation provenance. Under candidate 1 each publication carries one descriptor, that branch takes commit unchanged, and the companion change should not be made — it would be work prescribed by a withdrawn approach. Recorded rather than deleted, because a consumer-degradation finding that turned out to be avoidable is worth knowing was considered.

What survives from this section, unchanged and still load-bearing: both paragraphs above it. The applyDockZoneOperation slot is still occupied invisibly by three callers the declaration does not acknowledge, so no base-document parameter may be added — that finding is independent of which candidate won, and it is what made candidate 3 insufficient. And the reduction must still route through that seam rather than calling Operations.applyOperation directly, or a consumer's own reducer is bypassed.

Workstation is determined, not inferred. @neo-gpt-emmy supplied the real-participant evidence at IC_kwDODSospM8AAAABTKqD7Q: base-red / fix-green against an actual Workstation participant, plus a 21-call syntax and wrapper inventory. That is the observation AC-5 asks for and the enumeration AC-6 asks for, produced against the delivered repair rather than reasoned from the mechanism. The ACs stay unticked here because ticking them is the merge gate's act, not the author's.

Decision Record impact

depends-on ADR 0029 §2.7, and the dependency is now the whole story. The defect never challenged the record — §2.7's two-step requirement is exactly what makes the sequence correct on the direct path. The withdrawn candidate 2 would have contradicted the row's "each step commits independently" clause and required an amendment; the accepted candidate 1 conforms to it, so no amendment is needed and none should be spent on an implementation preference.

Acceptance Criteria

  • A red-first arm reproduces it without a browser: a workspace with a registered Group participant, an edge-owned item, pin → restore → pin, asserting the second pin reaches autoHidden. Fails on current dev.
  • The second pin takes effect under a Group participant.
  • The direct (no-participant) path is unchanged, with its own arm — the fix must not trade one path for the other.
  • The refusal stops being silent. RETIRED 2026-09-08 — not unmet, unmeetable as written. This AC named its own mechanism ("under candidate 2 this is structural — the transaction throws") and explicitly excluded the alternative ("not that instrumentation was added"). It was a rider on candidate 2: it claimed loudness would arrive free. Candidate 2 is withdrawn, the free route is gone, and the only remaining route is the one this AC ruled out — so satisfying it here would mean doing the thing it forbade. Retired rather than weakened, per "never weaken an AC to something implementable". Its surviving substance turned out to be nothing. I ticketed it as #18455 — the projection wires header actions as a listener slot, so nothing above consumes a return or a rejection — and then retracted and closed that ticket the same hour under an operator premise challenge: a projection is a render target whose only input is the committed document, so it learns a refusal by the document not changing. The engine already carries a structured return for programmatic callers and a fired dock*Settled event where a host must react; the projection consuming outcomes would be a second truth path in the render layer. Do not follow #18455 expecting an owner — it is closed not-planned and there is nothing for a successor to hold. The one real residual, the unhandled rejection, is fixed in PR #18453 at the handler, which is where it belongs.
  • Whether apps/workstation is affected is determined and recorded — it registers a participant, so by this mechanism it should be, and its tier does not cover a repeated pin. "Broken too" and "masks it" need different follow-ups.
  • Any other engine site that publishes more than once in one synchronous pass is enumerated, with a disposition per site.

Out of Scope

  • ADR 0029 §2.7's two-step requirement itself. The sequence is right; its dependency on a synchronous field advance is what fails.
  • The three other consumer arms that fail alongside this one under Group registration (a tab-switching arm and two widget-lifecycle arms). They are plausibly the same class — engine sequences that publish more than once — but that is unmeasured, and one proven mechanism for one arm is what this ticket carries.
  • Neo.logError swallowing the Group commit rejection. Related and worth its own look, but the pin refusal here is a returned errors array, not a rejected promise, so it would not have surfaced either way.

Avoided Traps

  • Reading the passing first pin as "pin works under a Group". It does, and testing only that sends the diagnosis elsewhere. The failing arm pins three times; a probe shaped like the feature rather than like the failure measures the wrong claim.
  • Blaming the consumer. The obvious read is that the app mutates something it should not. It does not: the sequence, the refusal and the asynchrony are all engine-side, and the consumer only supplied a registered participant, which is the documented way in.
  • Candidate 3 as a standalone fix. Threading the base document is the natural first instinct and leaves the Group re-applying against a stale snapshot.

Related

  • #18446 — the FLIP marker gap found in the same migration; independent defect, same area.
  • Epics #18303 / #18304 — the ownership arc that introduced the Group commit path.
  • #18208 — a different Workstation-side refresh override; not this, listed so a reader does not conflate them.

Creation Gates

  • Live latest-open sweep: latest 20 open issues checked immediately before creation; no equivalent (nearest are #18208 refresh override and #18275 lazy-pane double construct, both different mechanisms).
  • A2A in-flight claim sweep: 12 most recent messages, all read-states, scanned immediately before creation; no overlapping [lane-claim] / [lane-intent].
  • Agent OS Structure Map Gate: N/A — no new or relocated .mjs file.

Handoff Retrieval Hints

  • query_raw_memories — "multi-step dock commit reduces against a stale document under a Group, pin sequence step 2 refused"
  • Grep anchors: handleDockPinAction, setItemAutoHidden in model/Operations.mjs, the Group branch in onDockZoneDocumentChange.
  • The probe shape that localises it: walk pin → reveal → restore → pin and census rail-tab count after every step; a single pin passes and proves nothing.

Origin Session ID: fffad262-5537-4e4a-869d-69bce1c81b6b