I filed this ticket claiming pop-out was a shipped precedent for reactive action gates, and prescribed making all six enableDock*Action configs reactive while deleting five projection-time branches. All three legs of that argument are wrong. Measured at dev@d94be7d48d, before a line was written:
F1 — popOutAvailable is a capability leaf, not a reactive opt-in.
The leaf carries whether a vessel can be produced at all — handler bundle plus realm. And LayoutAdapter:1220 still gates pop-out's existence on the plain context.enableDockPopOutAction, exactly like the other five. Pop-out's opt-in is construction-time too. There is no shipped precedent for reactive gates; the original ticket asserted one.
F2 — the gate transfers ownership of the action NAME, and that is a guarded contract.
// LayoutAdapter:1115 "Every action name is reserved exactly while its projection flag is on."
['close', context.enableDockCloseAction ? 'enableDockCloseAction' : null],
A host action colliding with an enabled engine name throws (:1139). With the flag off, the name is the host's. This is not incidental:
test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs:266 — "without the flag the name is the host's to take"
:332-340 — the reserved-name throw, asserted per flag
test/playwright/component/apps/dock-hostreload/app.mjs:7 — a whole component-tier fixture for the host-owned reload, proving "the compatibility opt-out is behaviorally INERT"
Deleting the five projection branches would red those specs. The original step 4 contradicted covered behaviour.
F3 — "an app cannot express hide close while read-only" was overstated. It can: opt out, then project its own close through resolveDockHeaderActions, whose result is spread verbatim into headerActions (LayoutAdapter:1154) and carries whatever keys the host supplies. What the app cannot do is keep the engine's behaviour while supplying its own visibility — see below. (Honest bound: the four test consumers supply static keys such as hidden/iconCls; no arm exercises bind on a host action. The spread is read from source, not measured.)
The one thing the census got right is the asymmetry itself, and it survives in a narrower form.
The Problem
Ownership and availability are the same config, and only one of them should be construction-time.
Each of the six enableDock*Action gates decides two independent things at once:
Who owns the intent — engine or host. Workspace.mjs:1644 refuses to handle an action whose gate is off (action === 'close' && me.enableDockCloseAction), so opting out hands over the behaviour along with the name. Construction-time is correct here: a reactive ownership transfer would have to throw against a host action already holding the name, at a point where nothing can handle the throw.
Whether the action is currently available — which is runtime policy, and today has no consumer channel at all.
So the consumer's choice is all-or-nothing:
what the app wants
today
engine's close behaviour, engine's per-item policy
✅ enableDockCloseAction: true
own behaviour, own policy
✅ gate false + resolveDockHeaderActions
engine's behaviour, app's visibility policy
❌ no mechanism
The third row is the gap. An app that wants "the engine's close, hidden while this session is read-only" must re-implement handleDockCloseAction — document mutation through the reducer, empty-TabContainer pruning, the focus fallback — to change one boolean.
The engine already answers the equivalent question for its own internals: pop-out consults dock.popOutAvailable in its hidden() formula. The mechanism is one leaf read. It is simply not reachable by a consumer for any of the six.
Every writer of a dock.* leaf lives inside src/dashboard/dock/ — zero in apps/ or examples/. publishDocument republishes those leaves from the committed document on every commit, so a consumer write into that namespace would be clobbered. The channel has to live outside it.
The Architectural Reality
HeaderActionPolicy.mjs:185createActionBindings(nodeId) — the binding factory. Its formulas run with the provider as scope (this.getData(...)), one per action.
HeaderActionPolicy.mjs:252publishDocument(document) — engine-owned leaves, republished per commit, self-diffing.
state/Provider.mjs:566getData(key) → getOwnerOfDataProperty(key) — resolves up the provider hierarchy. A key an ancestor provider owns is readable from the workspace's provider unchanged, and an unowned key returns undefined rather than throwing.
That last fact is the whole fix: the app publishes its policy on its own provider, and the dock's existing formulas read it through the hierarchy the engine already has.
The Fix
Give the engine's hidden() formulas one additional read, in a namespace the engine never writes:
dockActionPolicy.<action>: false hides; absent or anything else leaves today's per-item policy in charge. The consumer declares it on its own state.Provider and binds it like any other datum — which is exactly "state provider bindings into dock layouts", without a single new engine config.
Nothing is added to src/dashboard/dock/Workspace.mjs — no configs, no methods. That layer carries a standing operator directive to stop adding to it (PR #18213, closed on the directive despite an approved diff). The delta is HeaderActionPolicy plus one added bind key in LayoutAdapter's maximize block: that action is the only one of the six projecting no formatter today, so without it AC-5 could not be met. No projection branch is deleted — F2's guarded contract stays exactly as it is.
The one trap, and it is measured prior art
An Effect that reads an absent key registers no dependency, because getData returns undefined without touching a Config. state.Provider#setConfigValue re-runs only the owner provider's bindings when a new key appears, so a consumer that publishes dockActionPolicy.closeafter the dock's bindings first run gets silence, not a re-render. This is the same hazard #18307 hit and fixed by seeding the leaves a formatter reads (flights.<id>: null) before its first run.
Consequence for this ticket: the supported path is a declared key in the consumer provider's data. An arm must prove the late-setData path either works or is documented as unsupported — asserting only the declared path would hide it.
gains bind: bindings.maximize — the only engine action with no formatter
bind: undefined for direct adapter consumers, as close already does
—
:1247bind: bindings.close precedent
dock.* leaves
HeaderActionPolicy:252
not the channel — republished per commit
n/a
—
publishDocument
Decision Record impact
aligned-with ADR 0029 — no amendment. No persisted wire format changes and the projection contract is untouched; this adds one documented consumer namespace to an existing binding layer.
Acceptance Criteria
With dockActionPolicy.close: false declared on an ancestor provider, a live, already-projected workspace hides the engine's close action, with no re-projection call — an arm that fails on dev today.
⚠️ The inverse arm: flipping it back to true restores the action. Both halves visited — an arm that only turns policy off is testing its beforeEach.
The action still behaves as the engine's: with policy re-enabled, close prunes and routes through handleDockCloseAction exactly as before. This is the row the ticket exists for; a fix that only hides is a worse resolveDockHeaderActions.
⚠️ A key first published after the bindings' first run is covered by an arm — either it re-renders, or the arm asserts the documented limitation. The absent-key dependency gap is silent, so an untested claim here is worthless.
All six actions accept the namespace, asserted by enumeration rather than one representative.
A consumer declaring no policy sees byte-identical projected headers — enableDock*Action semantics, the reserved-name throw and the host-ownership opt-out are all unchanged. DockLayoutAdapter.spec.mjs, DockLockAction.spec.mjs, DockWorkspace.spec.mjs and the dock-hostreload component app stay green unmodified; a diff that edits them is changing the contract F2 established.
src/dashboard/dock/Workspace.mjs line count unchanged.
Out of Scope
Making the six gates reactive. Falsified above (F1/F2). They are ownership declarations.
A consumer write channel into dock.*. Engine-owned and republished per commit; the consumer namespace is deliberately separate.
Per-item policy.closable / lockable / locked / pinnable / reloadable already work and are untouched; this is the workspace-level axis.
enableDockTearOutLifecycle — a lifecycle opt-in, not a header action.
panes / zones reactivity — deferred by #18474's charter to the bound-panes tier, and the larger reading of "state provider bindings into dock layouts".
A reactive perspective/layout selection config — separate gap, raised at D#18468 (DC_kwDODSospM4BGGgR).
Documenting the host-action bind seam. Real (F3: it has no consumer and no arm) but it belongs to #18036, not here.
Avoided Traps
Adding availability configs to Workspace.mjs. Six reactive dockCloseActionAvailable_-style configs is the obvious shape and it lands squarely on the layer PR #18213 closed for additions.
Reusing the dock.* namespace.publishDocument rewrites it from the document each commit, so a consumer write survives until the next commit and then vanishes — a defect that looks like a race.
Trusting a cited precedent without reading it. F1 is exactly that failure: popOutAvailable was cited as reactive-gate prior art by its own author, in this ticket, and it is a capability predicate. Grace and I made the same class of error on wrapper/Mermaid.mjs the same evening — two reviewers citing one sibling, neither auditing it.
Deleting the projection branches for symmetry. They carry name reservation, not just existence, and three specs plus a component app assert it.
Related
#18307 / PR #18367 — shipped the header binding layer, and the measured source of the absent-key seeding trap.
#18036 — the documentation home for the surviving host-action bind gap (F3).
PR #18213 (closed) — the standing directive against additions to src/dashboard/dock/Workspace.mjs.
#18032 — the guide ticket documenting this surface.
Sweeps. Body replaced at 2026-09-10T11:49Z after a drift probe against dev@d94be7d48d falsified the original prescription; the original text is in this issue's edit history. Gate census, popOutAvailable derivation, reserved-name map, the four resolveDockHeaderActions test consumers and Provider#getData's hierarchy walk all re-measured on that head. Memory Core sweep on the absent-key hazard surfaced #18307's own fix as prior art. No open equivalent; no [lane-claim] overlap.
Falsified premise — read this first
I filed this ticket claiming pop-out was a shipped precedent for reactive action gates, and prescribed making all six
enableDock*Actionconfigs reactive while deleting five projection-time branches. All three legs of that argument are wrong. Measured atdev@d94be7d48d, before a line was written:F1 —
popOutAvailableis a capability leaf, not a reactive opt-in.// Workspace.mjs:1401 get dockPopOutActionActive() return me.enableDockPopOutAction === true && !!me.tearOutHandlers && (!me.enableDockTearOutLifecycle || me.canOpenTearOutVessel)The leaf carries whether a vessel can be produced at all — handler bundle plus realm. And
LayoutAdapter:1220still gates pop-out's existence on the plaincontext.enableDockPopOutAction, exactly like the other five. Pop-out's opt-in is construction-time too. There is no shipped precedent for reactive gates; the original ticket asserted one.F2 — the gate transfers ownership of the action NAME, and that is a guarded contract.
// LayoutAdapter:1115 "Every action name is reserved exactly while its projection flag is on." ['close', context.enableDockCloseAction ? 'enableDockCloseAction' : null],A host action colliding with an enabled engine name throws (
:1139). With the flag off, the name is the host's. This is not incidental:test/playwright/unit/dashboard/DockLayoutAdapter.spec.mjs:266— "without the flag the name is the host's to take":332-340— the reserved-name throw, asserted per flagtest/playwright/component/apps/dock-hostreload/app.mjs:7— a whole component-tier fixture for the host-ownedreload, proving "the compatibility opt-out is behaviorally INERT"Deleting the five projection branches would red those specs. The original step 4 contradicted covered behaviour.
F3 — "an app cannot express hide close while read-only" was overstated. It can: opt out, then project its own
closethroughresolveDockHeaderActions, whose result is spread verbatim intoheaderActions(LayoutAdapter:1154) and carries whatever keys the host supplies. What the app cannot do is keep the engine's behaviour while supplying its own visibility — see below. (Honest bound: the four test consumers supply static keys such ashidden/iconCls; no arm exercisesbindon a host action. The spread is read from source, not measured.)The one thing the census got right is the asymmetry itself, and it survives in a narrower form.
The Problem
Ownership and availability are the same config, and only one of them should be construction-time.
Each of the six
enableDock*Actiongates decides two independent things at once:Workspace.mjs:1644refuses to handle an action whose gate is off (action === 'close' && me.enableDockCloseAction), so opting out hands over the behaviour along with the name. Construction-time is correct here: a reactive ownership transfer would have to throw against a host action already holding the name, at a point where nothing can handle the throw.So the consumer's choice is all-or-nothing:
enableDockCloseAction: truefalse+resolveDockHeaderActionsThe third row is the gap. An app that wants "the engine's close, hidden while this session is read-only" must re-implement
handleDockCloseAction— document mutation through the reducer, empty-TabContainerpruning, the focus fallback — to change one boolean.The engine already answers the equivalent question for its own internals:
pop-outconsultsdock.popOutAvailablein itshidden()formula. The mechanism is one leaf read. It is simply not reachable by a consumer for any of the six.Measured, so the scope cannot drift
Every writer of a
dock.*leaf lives insidesrc/dashboard/dock/— zero inapps/orexamples/.publishDocumentrepublishes those leaves from the committed document on every commit, so a consumer write into that namespace would be clobbered. The channel has to live outside it.The Architectural Reality
HeaderActionPolicy.mjs:185createActionBindings(nodeId)— the binding factory. Its formulas run with the provider as scope (this.getData(...)), one per action.HeaderActionPolicy.mjs:252publishDocument(document)— engine-owned leaves, republished per commit, self-diffing.state/Provider.mjs:566getData(key)→getOwnerOfDataProperty(key)— resolves up the provider hierarchy. A key an ancestor provider owns is readable from the workspace's provider unchanged, and an unowned key returnsundefinedrather than throwing.That last fact is the whole fix: the app publishes its policy on its own provider, and the dock's existing formulas read it through the hierarchy the engine already has.
The Fix
Give the engine's
hidden()formulas one additional read, in a namespace the engine never writes:// HeaderActionPolicy.createActionBindings — inside each action's hidden() const policy = provider => provider.getData('dockActionPolicy.close'); … hidden() { if (policy(this) === false) { return true } /* existing per-item policy unchanged */ }dockActionPolicy.<action>:falsehides; absent or anything else leaves today's per-item policy in charge. The consumer declares it on its ownstate.Providerand binds it like any other datum — which is exactly "state provider bindings into dock layouts", without a single new engine config.Nothing is added to
src/dashboard/dock/Workspace.mjs— no configs, no methods. That layer carries a standing operator directive to stop adding to it (PR #18213, closed on the directive despite an approved diff). The delta isHeaderActionPolicyplus one addedbindkey inLayoutAdapter's maximize block: that action is the only one of the six projecting no formatter today, so without it AC-5 could not be met. No projection branch is deleted — F2's guarded contract stays exactly as it is.The one trap, and it is measured prior art
An Effect that reads an absent key registers no dependency, because
getDatareturnsundefinedwithout touching aConfig.state.Provider#setConfigValuere-runs only the owner provider's bindings when a new key appears, so a consumer that publishesdockActionPolicy.closeafter the dock's bindings first run gets silence, not a re-render. This is the same hazard #18307 hit and fixed by seeding the leaves a formatter reads (flights.<id>: null) before its first run.Consequence for this ticket: the supported path is a declared key in the consumer provider's
data. An arm must prove the late-setDatapath either works or is documented as unsupported — asserting only the declared path would hide it.Contract Ledger Matrix
dockActionPolicy.<action>falsehides the engine actionProvider.mjs:566hierarchy walkHeaderActionPolicy.createActionBindingsHeaderActionPolicy.mjs:185hidden()consults the namespace first:218pop-out precedent for the shapeenableDock*Action× 6Workspace.mjs:198…285LayoutAdapter:1115+ 3 specsLayoutAdapterprojection branchesLayoutAdapter:1115/1160…1265LayoutAdaptermaximize blockLayoutAdapter:1234bind: bindings.maximize— the only engine action with no formatterbind: undefinedfor direct adapter consumers, as close already does:1247bind: bindings.closeprecedentdock.*leavesHeaderActionPolicy:252publishDocumentDecision Record impact
aligned-with ADR 0029— no amendment. No persisted wire format changes and the projection contract is untouched; this adds one documented consumer namespace to an existing binding layer.Acceptance Criteria
dockActionPolicy.close: falsedeclared on an ancestor provider, a live, already-projected workspace hides the engine's close action, with no re-projection call — an arm that fails ondevtoday.truerestores the action. Both halves visited — an arm that only turns policy off is testing itsbeforeEach.closeprunes and routes throughhandleDockCloseActionexactly as before. This is the row the ticket exists for; a fix that only hides is a worseresolveDockHeaderActions.enableDock*Actionsemantics, the reserved-name throw and the host-ownership opt-out are all unchanged.DockLayoutAdapter.spec.mjs,DockLockAction.spec.mjs,DockWorkspace.spec.mjsand thedock-hostreloadcomponent app stay green unmodified; a diff that edits them is changing the contract F2 established.src/dashboard/dock/Workspace.mjsline count unchanged.Out of Scope
dock.*. Engine-owned and republished per commit; the consumer namespace is deliberately separate.closable/lockable/locked/pinnable/reloadablealready work and are untouched; this is the workspace-level axis.enableDockTearOutLifecycle— a lifecycle opt-in, not a header action.panes/zonesreactivity — deferred by #18474's charter to the bound-panes tier, and the larger reading of "state provider bindings into dock layouts".DC_kwDODSospM4BGGgR).bindseam. Real (F3: it has no consumer and no arm) but it belongs to #18036, not here.Avoided Traps
Workspace.mjs. Six reactivedockCloseActionAvailable_-style configs is the obvious shape and it lands squarely on the layer PR #18213 closed for additions.dock.*namespace.publishDocumentrewrites it from the document each commit, so a consumer write survives until the next commit and then vanishes — a defect that looks like a race.popOutAvailablewas cited as reactive-gate prior art by its own author, in this ticket, and it is a capability predicate. Grace and I made the same class of error onwrapper/Mermaid.mjsthe same evening — two reviewers citing one sibling, neither auditing it.Related
bindgap (F3).panes/zonesreactivity lives there.src/dashboard/dock/Workspace.mjs.Sweeps. Body replaced at 2026-09-10T11:49Z after a drift probe against
dev@d94be7d48dfalsified the original prescription; the original text is in this issue's edit history. Gate census,popOutAvailablederivation, reserved-name map, the fourresolveDockHeaderActionstest consumers andProvider#getData's hierarchy walk all re-measured on that head. Memory Core sweep on the absent-key hazard surfaced #18307's own fix as prior art. No open equivalent; no[lane-claim]overlap.Origin Session ID: 206b8400-7fe8-472e-8018-446e550b784b
Retrieval Hint:
query_raw_memories("dock header action ownership vs availability dockActionPolicy consumer provider namespace")· anchors areHeaderActionPolicy.mjs:185(formulas),LayoutAdapter.mjs:1115(name reservation) andProvider.mjs:566(hierarchy walk).— Vega (Opus 5, Claude Code) 🌿