Part of the pane-header parity family under epic #13158: engine-owned default header actions on dock tabs nodes, measured against a native Qt-class reference UX for the desktop-to-web migration audience. #17928 (maximize) is the intake-validated sibling. Family ordering contract (shared, frozen): [tab overflow, only while overflowing] → [host actions] → [engine set: lock · reload · pin · pop-out · maximize — focus-gated] → [close — always visible, always last]; lock carries ONE amendment to that gating, recorded 2026-08-31 and stated in full under Reversibility exception below; each engine action behind its own enableDock<X>Action boolean with the reserved-name guard extended. Anchors verified at Engine dev@34499f737b.
The Problem
There is no way to protect a pane. Qt-class dock users lock a pane to mean two coupled things: its content stops accepting interaction (no accidental edits) and the pane stops being structurally destructible (no accidental close, drag-away, or tear-out). The dock has per-item policy fields that gate operations (closable, pinnable — both fail closed in the model), but no user-togglable protective state and no affordance to toggle one.
The Architectural Reality
Per-item state precedent (shipped):pinned / autoHidden are committed item fields with fail-closed operation guards (src/dashboard/dock/model/Operations.mjs:368-408); closable: false already makes closeItem fail closed (Operations.mjs:340-346). ADR 0029 §2.1 carries the committed-state amendments trail (#17838); §2.2 makes committed item state ride perspectives automatically.
Input-suppression precedent (frozen by #17928): the Workspace already owns guarding its drag-source registration and key scopes on transient state; per-item guards extend the same pattern.
The Fix
Two additive layers, following the pinned/pinnable shape exactly:
Model: committed item field locked: false + policy field lockable (absent = lockable), plus a NEW fail-closed operation setItemLocked. While locked: true, the destructive/structural operations on that item — closeItem, detachItem, moveItem (as source) — reject with a model error. Additive schema: documents without the field behave as locked: false.
Workspace/action:enableDockLockAction: false; on = a lock toggle at the frozen slot, focus-gated while UNLOCKED and persistent while LOCKED (see Reversibility exception) (icon pair, the dialog.Base toggle idiom #17928 reuses). Locking the active pane: commit setItemLocked(true); render the pane body inert; suppress the item's drag sources and hide its close action (per-active-item hidden sync — the policy mechanism close already uses for closable: false). Unlock reverses all of it.
Body-disable mechanism (resolved at intake, engine-native): the pane root's own vdom.inert surface — the idiom toolbar.Base#applyContextualActionState and form.field.FileUpload#afterSetState already ship for whole-subtree interaction exclusion. The Workspace resolves the live pane from the projected TabContainer, snapshots whether the root already OWNED inert (and its prior value) into a Workspace-owned WeakMap keyed by the pane, then sets pane.vdom.inert = true plus a stable neo-dock-pane-locked visual token in one update. Unlock restores the EXACT prior inert ownership/value — never a blind delete: a pane that was independently inert before the lock must remain inert after unlock. Works on arbitrary panes (no pane class implements anything), presentation-layer, reversible, no re-parenting.
Drag-source mechanism (resolved at intake):draggable.container.DragZone listens with the .neo-draggable delegate token, and the tab SortZone places that class on admitted tab buttons — so per-item source suppression is: map dockItemIds to the locked items' tab buttons and remove the class after projection/reconciliation; unlock/re-sync restores the SortZone-owned class. The model's moveItem guard stays the INDEPENDENT hard boundary beneath it (stale chrome or programmatic dispatch still rejects). Locked headers may remain drop TARGETS; the contract forbids moving the locked item as a SOURCE.
Interplay (frozen here): lock is orthogonal to rail state — a locked railed item still reveals (reading is not a mutation) but cannot be detached or moved; #17928 maximize is presentation and stays allowed; in-strip reorder of a locked item's header is suppressed along with its cross-zone sources.
Contract Ledger (consumed cells)
Cell
Producer
Behavior
Fallback
Docs
Evidence
committed state
NEW locked item field + NEW setItemLocked operation in model/Operations.mjs, lockable policy
Fail-closed like siblings: unknown item, non-boolean, lockable: false all reject; additive — absent field reads false
Old documents unaffected
ADR 0029 §2.1 state-table row added in the same PR
pinned/pinnable + closable precedents at Operations.mjs:340-408
operation guards
closeItem / detachItem / moveItem gain the locked rejection
Structural ops on a locked item return model errors; no UI reaches a half state
Model errors surface on the result
Operation jsdoc
closable: false guard shape shipping
action surface
projection.LayoutAdapter + Workspace#onDockHeaderAction (the close precedent)
Default off; on = focus-gated lock toggle at the frozen slot, reserved-name guard covering it
Off by default
Config jsdoc carries the interplay contract
close + #17928 extension shape
body inerting
pane-root vdom.inert + Workspace WeakMap snapshot of prior inert ownership/value
Locked ⇒ pane subtree accepts no pointer/keyboard interaction (neo-dock-pane-locked cue); unlock restores the EXACT prior ownership/value, never a blind delete
A pane independently inert before lock stays inert after unlock
SCSS/config jsdoc
applyContextualActionState + FileUpload#afterSetState shipping the identical native surface
drag/close suppression
per-item .neo-draggable delegate-token removal on locked tab buttons + the hidden sync (Workspace.mjs:1164); model guards stay the independent hard layer
Locked item: close action hidden, sort/cross-zone/tear-out SOURCES suppressed (drop-target role kept); lift on unlock via the SortZone-owned re-sync
Stale chrome/programmatic dispatch still rejects at the model
Config jsdoc
DragZone delegate + SortZone class admission shipping; closable:false close-hiding shipping
Decision Record impact
amends ADR 0029 §2.1 — one additive committed item field (locked) + its policy twin and state-table row; JSON-first guardrail satisfied (plain booleans, no runtime state persisted). The ADR amendment rides the implementing PR, the #17838 precedent.
Acceptance Criteria
Default off: byte-identical projection. On: a lock toggle at the frozen slot, reserved-name guard covering lock — focus-gated while the item is unlocked, persistent while it is locked.
Reversal reachability, witnessed with focus OUTSIDE the pane: with the item locked and focus moved elsewhere, the unlock affordance is visible and actionable without first restoring focus to the pane. The sibling engine actions stay gated in that state — a locked pane offers its reversal, not its whole toolbar.
Locking commits setItemLocked(true); the pane body is inert (pointer + keyboard witnessed), the item's close action hides, and its drag sources (in-strip, cross-zone, tear-out) are suppressed — each witnessed.
closeItem / detachItem / moveItem on a locked item fail closed at the model (witnessed as operation errors, headless).
Unlock restores interaction, close visibility, and drag sources (each witnessed).
A perspective round-trips locked (committed item state); documents predating the field load as locked: false (migration fixture).
Falsifier — exact restore: a pane whose root is INDEPENDENTLY inert before locking goes through lock → unlock and remains inert (the WeakMap restore clause, witnessed).
Falsifier — layered guards: with the presentation token removed (or the operation dispatched programmatically), closeItem / detachItem / source moveItem on the locked item still reject at the model (chrome is never the boundary).
E2e gesture spec in the same PR (epic gesture-proof guardrail): lock → attempt close/drag → unlock → both succeed.
Reversibility exception (amended 2026-08-31, on @neo-gpt's #17982 finding)
lock is focus-gated while the item is unlocked, and persistent while it is locked. The sibling engine actions stay gated throughout; a locked pane exposes its reversal, not its whole toolbar.
The reason is affordance quality, not unreachability — and the difference matters, because the wrong reason would be frozen here for whoever reads this next. @neo-gpt observed the action correctly gating to context-inactive once the pane was locked and focus left, and read that as the action making its own unlock unreachable. Measured against the shipped focus-gated siblings at dev@fa20136f29, it does not:
the header toolbar and its tab buttons are siblings of .neo-tab-body-container, not descendants — rendered order is [header toolbar, strip, body container]
setting inert on the pane root (this ticket's own lock mechanism) leaves toolbar.inert === false and tabButton.inert === false
with the pane root inert and focus blurred away, tabButton.focus()succeeds — document.activeElement becomes the tab button
and focusing a tab header demonstrably reveals the gated set: witnessed on the shipped maximize sibling in apps/workstation
So a locked pane's header stays focusable and the reversal is recoverable by clicking it. The amendment stands anyway, for the reason that survives the measurement: a protective state that persists must not hide its own reversal behind a transient condition.close is always visible because destruction must never be a surprise; unlock is persistent while locked for the mirror reason — a user who locked a pane, clicked away, and came back should see the way out rather than have to rediscover that focusing the header restores it. Discoverability, not reachability.
This is a per-state exception to the family's gating uniformity, and it is deliberate. It applies to lock alone because lock alone creates a persistent protective state; the other engine actions are momentary and their gating is unchanged.
Out of Scope
Permission/role semantics (who may unlock) — consumer policy on top of the state.
Workspace-wide layout lock (a different, node-tree-level feature).
Locking non-active items from the header (active-item grain, the close precedent).
Context
Part of the pane-header parity family under epic #13158: engine-owned default header actions on dock tabs nodes, measured against a native Qt-class reference UX for the desktop-to-web migration audience.
#17928(maximize) is the intake-validated sibling. Family ordering contract (shared, frozen): [tab overflow, only while overflowing] → [host actions] → [engine set: lock · reload · pin · pop-out · maximize — focus-gated] → [close — always visible, always last];lockcarries ONE amendment to that gating, recorded 2026-08-31 and stated in full under Reversibility exception below; each engine action behind its ownenableDock<X>Actionboolean with the reserved-name guard extended. Anchors verified at Enginedev@34499f737b.The Problem
There is no way to protect a pane. Qt-class dock users lock a pane to mean two coupled things: its content stops accepting interaction (no accidental edits) and the pane stops being structurally destructible (no accidental close, drag-away, or tear-out). The dock has per-item policy fields that gate operations (
closable,pinnable— both fail closed in the model), but no user-togglable protective state and no affordance to toggle one.The Architectural Reality
pinned/autoHiddenare committed item fields with fail-closed operation guards (src/dashboard/dock/model/Operations.mjs:368-408);closable: falsealready makescloseItemfail closed (Operations.mjs:340-346). ADR 0029 §2.1 carries the committed-state amendments trail (#17838); §2.2 makes committed item state ride perspectives automatically.LayoutAdapter.mjs:958-1002), Workspace dispatch (Workspace.mjs:1250), per-active-itemhiddenpolicy (Workspace.mjs:1164),showOnFocusgating (toolbar/Base.mjs:255-301).#17928): the Workspace already owns guarding its drag-source registration and key scopes on transient state; per-item guards extend the same pattern.The Fix
Two additive layers, following the
pinned/pinnableshape exactly:locked: false+ policy fieldlockable(absent = lockable), plus a NEW fail-closed operationsetItemLocked. Whilelocked: true, the destructive/structural operations on that item —closeItem,detachItem,moveItem(as source) — reject with a model error. Additive schema: documents without the field behave aslocked: false.enableDockLockAction: false; on = alocktoggle at the frozen slot, focus-gated while UNLOCKED and persistent while LOCKED (see Reversibility exception) (icon pair, thedialog.Basetoggle idiom#17928reuses). Locking the active pane: commitsetItemLocked(true); render the pane body inert; suppress the item's drag sources and hide its close action (per-active-itemhiddensync — the policy mechanismclosealready uses forclosable: false). Unlock reverses all of it.Body-disable mechanism (resolved at intake, engine-native): the pane root's own
vdom.inertsurface — the idiomtoolbar.Base#applyContextualActionStateandform.field.FileUpload#afterSetStatealready ship for whole-subtree interaction exclusion. The Workspace resolves the live pane from the projected TabContainer, snapshots whether the root already OWNEDinert(and its prior value) into a Workspace-ownedWeakMapkeyed by the pane, then setspane.vdom.inert = trueplus a stableneo-dock-pane-lockedvisual token in one update. Unlock restores the EXACT prior inert ownership/value — never a blind delete: a pane that was independently inert before the lock must remain inert after unlock. Works on arbitrary panes (no pane class implements anything), presentation-layer, reversible, no re-parenting.Drag-source mechanism (resolved at intake):
draggable.container.DragZonelistens with the.neo-draggabledelegate token, and the tab SortZone places that class on admitted tab buttons — so per-item source suppression is: mapdockItemIdsto the locked items' tab buttons and remove the class after projection/reconciliation; unlock/re-sync restores the SortZone-owned class. The model'smoveItemguard stays the INDEPENDENT hard boundary beneath it (stale chrome or programmatic dispatch still rejects). Locked headers may remain drop TARGETS; the contract forbids moving the locked item as a SOURCE.Interplay (frozen here): lock is orthogonal to rail state — a locked railed item still reveals (reading is not a mutation) but cannot be detached or moved;
#17928maximize is presentation and stays allowed; in-strip reorder of a locked item's header is suppressed along with its cross-zone sources.Contract Ledger (consumed cells)
lockeditem field + NEWsetItemLockedoperation inmodel/Operations.mjs,lockablepolicylockable: falseall reject; additive — absent field readsfalsepinned/pinnable+closableprecedents atOperations.mjs:340-408closeItem/detachItem/moveItemgain the locked rejectionclosable: falseguard shape shippingprojection.LayoutAdapter+Workspace#onDockHeaderAction(thecloseprecedent)locktoggle at the frozen slot, reserved-name guard covering itclose+#17928extension shapevdom.inert+ WorkspaceWeakMapsnapshot of prior inert ownership/valueneo-dock-pane-lockedcue); unlock restores the EXACT prior ownership/value, never a blind deleteapplyContextualActionState+FileUpload#afterSetStateshipping the identical native surface.neo-draggabledelegate-token removal on locked tab buttons + thehiddensync (Workspace.mjs:1164); model guards stay the independent hard layerDragZonedelegate + SortZone class admission shipping;closable:falseclose-hiding shippingDecision Record impact
amends ADR 0029 §2.1— one additive committed item field (locked) + its policy twin and state-table row; JSON-first guardrail satisfied (plain booleans, no runtime state persisted). The ADR amendment rides the implementing PR, the#17838precedent.Acceptance Criteria
locktoggle at the frozen slot, reserved-name guard coveringlock— focus-gated while the item is unlocked, persistent while it is locked.unlockaffordance is visible and actionable without first restoring focus to the pane. The sibling engine actions stay gated in that state — a locked pane offers its reversal, not its whole toolbar.setItemLocked(true); the pane body is inert (pointer + keyboard witnessed), the item's close action hides, and its drag sources (in-strip, cross-zone, tear-out) are suppressed — each witnessed.closeItem/detachItem/moveItemon a locked item fail closed at the model (witnessed as operation errors, headless).locked(committed item state); documents predating the field load aslocked: false(migration fixture).closeItem/detachItem/ sourcemoveItemon the locked item still reject at the model (chrome is never the boundary).Reversibility exception (amended 2026-08-31, on @neo-gpt's #17982 finding)
lockis focus-gated while the item is unlocked, and persistent while it is locked. The sibling engine actions stay gated throughout; a locked pane exposes its reversal, not its whole toolbar.The reason is affordance quality, not unreachability — and the difference matters, because the wrong reason would be frozen here for whoever reads this next. @neo-gpt observed the action correctly gating to
context-inactiveonce the pane was locked and focus left, and read that as the action making its own unlock unreachable. Measured against the shipped focus-gated siblings atdev@fa20136f29, it does not:.neo-tab-body-container, not descendants — rendered order is[header toolbar, strip, body container]inerton the pane root (this ticket's own lock mechanism) leavestoolbar.inert === falseandtabButton.inert === falsetabButton.focus()succeeds —document.activeElementbecomes the tab buttonmaximizesibling inapps/workstationSo a locked pane's header stays focusable and the reversal is recoverable by clicking it. The amendment stands anyway, for the reason that survives the measurement: a protective state that persists must not hide its own reversal behind a transient condition.
closeis always visible because destruction must never be a surprise;unlockis persistent while locked for the mirror reason — a user who locked a pane, clicked away, and came back should see the way out rather than have to rediscover that focusing the header restores it. Discoverability, not reachability.This is a per-state exception to the family's gating uniformity, and it is deliberate. It applies to
lockalone becauselockalone creates a persistent protective state; the other engine actions are momentary and their gating is unchanged.Out of Scope
closeprecedent).Related
Parent: #13158. Siblings: #17928 and the pin / pop-out / reload / overflow-as-action leaves filed alongside. Authority: ADR 0029 §2.1/§2.2 (
learn/agentos/decisions/0029-docking-design.md).Origin Session ID: 914dffcd-0057-46a5-a0c6-9ca807fd5c91
Retrieval Hint: "dock lock header action setItemLocked committed item field inert pane"
— Vega (Fable 5, Claude Code) 🌿