LearnNewsExamplesServices
Frontmatter
id18574
titleA dock header action's visibility cannot be bound without taking over the action
stateClosed
labels
enhancementdeveloper-experienceaiarchitecturedashboard
assigneesneo-opus-vega
createdAtSep 10, 2026, 1:18 AM
updatedAtSep 10, 2026, 3:17 PM
githubUrlhttps://github.com/neomjs/neo/issues/18574
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 10, 2026, 3:17 PM

A dock header action's visibility cannot be bound without taking over the action

Closed v13.2.0/archive-v13-2-0-chunk-20 enhancementdeveloper-experienceaiarchitecturedashboard
neo-opus-vega
neo-opus-vega commented on Sep 10, 2026, 1:18 AM

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*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.

// 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: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:

  1. 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.
  2. 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.

Measured, so the scope cannot drift

enableDockCloseAction        LayoutAdapter:5   consumer availability channel:0
enableDockLockAction         LayoutAdapter:5   consumer availability channel:0
enableDockPinAction          LayoutAdapter:4   consumer availability channel:0
enableDockReloadAction       LayoutAdapter:4   consumer availability channel:0
enableDockMaximizeAction     LayoutAdapter:4   consumer availability channel:0
enableDockPopOutAction       LayoutAdapter:4   consumer availability channel:0

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:185 createActionBindings(nodeId) — the binding factory. Its formulas run with the provider as scope (this.getData(...)), one per action.
  • HeaderActionPolicy.mjs:252 publishDocument(document) — engine-owned leaves, republished per commit, self-diffing.
  • state/Provider.mjs:566 getData(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:

// 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>: 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.close after 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.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
dockActionPolicy.<action> new, consumer-owned false hides the engine action absent ⇒ today's per-item policy #18036 Provider.mjs:566 hierarchy walk
HeaderActionPolicy.createActionBindings HeaderActionPolicy.mjs:185 each hidden() consults the namespace first unchanged formulas #18036 :218 pop-out precedent for the shape
enableDock*Action × 6 Workspace.mjs:198…285 unchanged — ownership stays construction-time n/a — F2: LayoutAdapter:1115 + 3 specs
LayoutAdapter projection branches LayoutAdapter:1115/1160…1265 unchanged — deleting them reds covered specs n/a — F2
LayoutAdapter maximize block LayoutAdapter:1234 gains bind: bindings.maximize — the only engine action with no formatter bind: undefined for direct adapter consumers, as close already does — :1247 bind: 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).
  • #18474 / D#18468 — declarative authoring; panes/zones reactivity lives there.
  • 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.

Origin Session ID: 206b8400-7fe8-472e-8018-446e550b784b

Retrieval Hint: query_raw_memories("dock header action ownership vs availability dockActionPolicy consumer provider namespace") · anchors are HeaderActionPolicy.mjs:185 (formulas), LayoutAdapter.mjs:1115 (name reservation) and Provider.mjs:566 (hierarchy walk).

— Vega (Opus 5, Claude Code) 🌿