Context
Operator report on a downstream dock host (2026-09-03): clicking the lock action makes every tab-container header in the app flicker — "so I assume all of them update, for triggering an action that affects the content of its tab. serious no-go."
Reproduced and measured headless on the served app. The first sub of #18151: the seam where the engine's declining default is cheapest to fix and most visible.
The Problem
setItemLocked sets one boolean on one item. It changes no node, no split, no edge zone. It nevertheless takes the full staged transaction, because Workspace#getRefreshOptions(descriptor, source) returns {} by default — documented as "the default takes the full staged transaction every time" — and a consumer that does not override it never gets anything narrower.
Measured, one lock click, MutationObserver over the whole app subtree:
TOTAL mutations : 32
inside a tab header : 7 (the locked pane's own header)
OUTSIDE any header : 25
on the dock host : 4
distinct non-header targets: neo-dashboard-dock-split (childList add/remove pairs),
neo-dashboard-dock-edge-row, neo-dashboard-dock-edge-zone,
neo-body, dock-flip-item-*, neo-dashboard-dock-query-hostThe mechanism is re-parenting, not re-creation — which is why it is easy to miss. Three earlier arms of mine found nothing: header DOM nodes are retained (object permanence holds — 3 of 3 survived a marker stamped on the node), and the surviving headers show zero internal mutations because syncDock*Action writes only on a value change. The churn is one level up: the splits and edge rows have their children removed and re-added, so every retained header is re-parented, and a re-parented node repaints. An observer scoped to the headers cannot see its own cause.
{retainTopology} — "in-place item reconciliation on a proven-identical structural shell" — is exactly the shape this operation needs, and nothing asks for it.
The Architectural Reality
src/dashboard/dock/Workspace.mjs — getRefreshOptions(descriptor, source) { return {} }; the JSDoc names {geometryOnly} for a pure resizeSplit, {retainTopology} for item-only deltas, {preserveItemIds} for per-commit owner-held ids.
refreshDockWorkspace reads {geometryOnly=false, retainTopology=false} from those options; getReconcileOptions can force retainTopology (forcedRetainTopology ?? retainTopology).
src/dashboard/dock/model/Operations.mjs owns the whole vocabulary — addTab, applyDocument, setActiveItem, moveItem, splitNode, moveNode, resizeSplit, resizeEdgeZone, detachItem, closeItem, setItemLocked, setItemPinned, setItemAutoHidden — and is therefore the only place that can classify them honestly.
Workspace#handleDockLockAction commits setItemLocked and calls onDockZoneDocumentChange, which schedules the refresh. The consumer under test overrides neither refresh hook, which is the intended-to-be-safe path.
The Fix
Declare the change-class per operation as data beside the reducer that owns it, and let the engine derive the refresh shape from the descriptor it already receives. setItemLocked, setItemPinned and setItemAutoHidden are item-flag deltas; resizeSplit and resizeEdgeZone are geometry; the rest restructure. getRefreshOptions stays as the host override, now layered over a correct default rather than being the only source of truth.
The classification belongs in model/Operations.mjs, not in a Workspace switch: the reducer that implements an operation is what knows what it touched, and co-locating them is what keeps a new operation from silently defaulting to "restage everything" or, worse, to a wrong fast path.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
| per-operation change-class |
src/dashboard/dock/model/Operations.mjs |
each operation declares topology / geometry / itemFlags beside its reducer |
an operation without a declaration is treated as topology (today's behaviour) |
JSDoc |
one arm per class; an undeclared operation stays full-transaction |
Workspace#getRefreshOptions |
src/dashboard/dock/Workspace.mjs |
derives from the descriptor's change-class; unchanged signature |
a host override still wins outright |
JSDoc |
override-wins arm, mutation-proven |
refreshDockWorkspace refreshOptions |
same |
unchanged contract (geometryOnly / retainTopology / preserveItemIds) |
unchanged |
JSDoc |
existing arms stay green |
Decision Record impact
aligned-with ADR 0029 — the docking design record's projection and object-permanence rules are what this restores in practice; no authority changes.
Acceptance Criteria
Out of Scope
- The recreate and vessel defaults — sibling seams under #18151.
- Any change to what lock means (content inert, structural verbs fail closed): presentation and policy are untouched.
- Consumer-side classification of operations, which is the layering defect #18151 names.
Avoided Traps
- Scoping the witness to the headers. That is what made three of my own measurement arms report a clean result on a defect the operator could see: node identity survives, internal mutations are zero, and the cause sits one level up. Observe the app subtree.
- A consumer-side
getRefreshOptions switch. It fixes one app, reproduces the defect in every other, and rots as the vocabulary grows.
- Reading flicker as a repaint-only cosmetic. The re-parenting is real DOM work on every commit; the visual flicker is the symptom, not the cost.
- Assuming
retainTopology is safe for every non-topology operation. It admits in-place reconciliation on a proven-identical shell; the declaration must come from the reducer, not from a guess about the operation's name.
Related
- Sub of #18151 — the declining-default class this is the first instance of.
- #18014 AC-1/AC-7/AC-9 (closed) — the default action vector and the single-lifecycle-owner invariant.
- #18039 (closed) — the post-settle header sweep that walks the settled shell.
- ADR 0029 — docking design record.
Live latest-open sweep: checked the latest 25 open issues at 2026-09-02T22:12Z plus the A2A claim window; no equivalent ticket and no competing claim.
Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b
Retrieval Hint: "lock toggle restages dock shell every header repaints getRefreshOptions retainTopology operation change class re-parenting"
Context
Operator report on a downstream dock host (2026-09-03): clicking the lock action makes every tab-container header in the app flicker — "so I assume all of them update, for triggering an action that affects the content of its tab. serious no-go."
Reproduced and measured headless on the served app. The first sub of #18151: the seam where the engine's declining default is cheapest to fix and most visible.
The Problem
setItemLockedsets one boolean on one item. It changes no node, no split, no edge zone. It nevertheless takes the full staged transaction, becauseWorkspace#getRefreshOptions(descriptor, source)returns{}by default — documented as "the default takes the full staged transaction every time" — and a consumer that does not override it never gets anything narrower.Measured, one lock click, MutationObserver over the whole app subtree:
TOTAL mutations : 32 inside a tab header : 7 (the locked pane's own header) OUTSIDE any header : 25 on the dock host : 4 distinct non-header targets: neo-dashboard-dock-split (childList add/remove pairs), neo-dashboard-dock-edge-row, neo-dashboard-dock-edge-zone, neo-body, dock-flip-item-*, neo-dashboard-dock-query-hostThe mechanism is re-parenting, not re-creation — which is why it is easy to miss. Three earlier arms of mine found nothing: header DOM nodes are retained (object permanence holds — 3 of 3 survived a marker stamped on the node), and the surviving headers show zero internal mutations because
syncDock*Actionwrites only on a value change. The churn is one level up: the splits and edge rows have their children removed and re-added, so every retained header is re-parented, and a re-parented node repaints. An observer scoped to the headers cannot see its own cause.{retainTopology}— "in-place item reconciliation on a proven-identical structural shell" — is exactly the shape this operation needs, and nothing asks for it.The Architectural Reality
src/dashboard/dock/Workspace.mjs—getRefreshOptions(descriptor, source) { return {} }; the JSDoc names{geometryOnly}for a pureresizeSplit,{retainTopology}for item-only deltas,{preserveItemIds}for per-commit owner-held ids.refreshDockWorkspacereads{geometryOnly=false, retainTopology=false}from those options;getReconcileOptionscan forceretainTopology(forcedRetainTopology ?? retainTopology).src/dashboard/dock/model/Operations.mjsowns the whole vocabulary —addTab,applyDocument,setActiveItem,moveItem,splitNode,moveNode,resizeSplit,resizeEdgeZone,detachItem,closeItem,setItemLocked,setItemPinned,setItemAutoHidden— and is therefore the only place that can classify them honestly.Workspace#handleDockLockActioncommitssetItemLockedand callsonDockZoneDocumentChange, which schedules the refresh. The consumer under test overrides neither refresh hook, which is the intended-to-be-safe path.The Fix
Declare the change-class per operation as data beside the reducer that owns it, and let the engine derive the refresh shape from the descriptor it already receives.
setItemLocked,setItemPinnedandsetItemAutoHiddenare item-flag deltas;resizeSplitandresizeEdgeZoneare geometry; the rest restructure.getRefreshOptionsstays as the host override, now layered over a correct default rather than being the only source of truth.The classification belongs in
model/Operations.mjs, not in aWorkspaceswitch: the reducer that implements an operation is what knows what it touched, and co-locating them is what keeps a new operation from silently defaulting to "restage everything" or, worse, to a wrong fast path.Contract Ledger Matrix
src/dashboard/dock/model/Operations.mjstopology/geometry/itemFlagsbeside its reducertopology(today's behaviour)Workspace#getRefreshOptionssrc/dashboard/dock/Workspace.mjsrefreshDockWorkspacerefreshOptionsgeometryOnly/retainTopology/preserveItemIds)Decision Record impact
aligned-with ADR 0029— the docking design record's projection and object-permanence rules are what this restores in practice; no authority changes.Acceptance Criteria
setItemLockedcommit on a workspace that overrides no refresh hook mutates nothing outside the affected pane's own header — asserted as a mutation count over the whole app subtree, not as a screenshot.model/Operations.mjscarries a change-class; an operation with none is treated as topology-changing, and a witness proves that fallback for a synthetic undeclared operation.setItemPinned/setItemAutoHiddenrail round trips andresizeSplit/resizeEdgeZonekeep their current behaviour, with the existing dock component suites green at exact head.getRefreshOptionsstill wins over the derived default, mutation-proven.Out of Scope
Avoided Traps
getRefreshOptionsswitch. It fixes one app, reproduces the defect in every other, and rots as the vocabulary grows.retainTopologyis safe for every non-topology operation. It admits in-place reconciliation on a proven-identical shell; the declaration must come from the reducer, not from a guess about the operation's name.Related
Live latest-open sweep: checked the latest 25 open issues at 2026-09-02T22:12Z plus the A2A claim window; no equivalent ticket and no competing claim.
Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b
Retrieval Hint: "lock toggle restages dock shell every header repaints getRefreshOptions retainTopology operation change class re-parenting"