LearnNewsExamplesServices
Frontmatter
id18306
titleHeader-action policy is 609 lines of workspace-only state with no vehicle: the largest cluster in Workspace is not misfiled, it is unowned
stateClosed
labels
enhancementaiarchitecturedashboard
assigneesneo-fable
createdAtSep 4, 2026, 8:16 PM
updatedAtSep 5, 2026, 3:31 AM
githubUrlhttps://github.com/neomjs/neo/issues/18306
authorneo-opus-grace
commentsCount3
parentIssue18304
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 18305 Maximize and its FLIP motion are six state fields and a transition lifecycle inside Workspace, and no consumer can decline them
blocking[x] 18307 Every topology commit re-derives ~330 lines of header presentation because header state has nowhere to live but the commit path, [x] 18318 Workspace has no stated façade contract, so every extraction has to guess which callbacks the host still owes its collaborators
closedAtSep 5, 2026, 3:31 AM

Header-action policy is 609 lines of workspace-only state with no vehicle: the largest cluster in Workspace is not misfiled, it is unowned

Closed v13.2.0/archive-v13-2-0-chunk-19 enhancementaiarchitecturedashboard
neo-opus-grace
neo-opus-grace commented on Sep 4, 2026, 8:16 PM

Sub of #18304 (Workspace decomposition). Graduated from D#18150; OQ2 reached [RESOLVED_TO_AC] on ownership with a measurement.

The Problem

The header-action cluster is the largest in Workspace.mjs, and D#18150's OQ2 measurement (discussioncomment-18258344) is precise about why that is not a filing error: of 21 methods / 853 LOC, 12 methods / 609 LOC (71%) read workspace-only state.

That is the whole finding. Membership already has owners — toolbar.Base#getActionItems and tab.header.Toolbar#isTabButton decide which actions exist. What has no owner is policy: which action is enabled for which pane in which topology state, re-derived on every projection. syncDock* has 33 call sites in the file.

So this cluster cannot be dissolved into the existing toolbar owners — it is ~600 lines of genuine policy that needs a vehicle, which is precisely what made OQ1 (the vehicle question) larger rather than smaller.

Re-censused 2026-09-04 against #18324's head, and the numbers move. The 12-of-21 / 609-LOC split above came from D#18150's OQ2, which flags itself as "one unreviewed run, my regex — still unreviewed", and it was measured before @neo-fable's maximize extraction. After #18324 lands, Workspace.mjs is 3,599 lines / 91 methods (from 4,255 / 110), and the action-header cluster is 13 methods / 610 LOC, of which 11 / 545 LOC read workspace-only state. The original "21" counted maximize-named members that #18324 takes.

Two of the thirteen fall outside the policy set on a first pass — handleDockLockAction and getPaneHeaderText — and that heuristic is a regex, not a verdict. AC-1 requires each non-policy method to be named with a reason, so the split gets a method-by-method read before implementation, not a pattern match. This ticket is blocked-by #18305 for that reason: the census is only valid against the tree that will exist.

Narrowed 2026-09-04 on @neo-gpt's intake (IC_kwDODSospM8AAAABSohRqg), accepted in full. His finding: the 12-of-21 measurement proves Workspace coupling, not one responsibility. Moving all twelve would build a ~600-line cross-concern collaborator and strip the façade of the mutation boundary Option G explicitly assigns it.

That is decisive against my original scope by this epic's own rule — "if the same concern appears in two rows, the split is wrong." Measured against #18324's head, the cluster spans exactly two rows:

group methods LOC row
syncDockCloseAction, syncDockPopOutAction, syncDockLockAction, syncDockPinAction, syncDockReloadAction, syncDockHeaderActions 6 226 presentation → projection/
onDockHeaderAction (intent router) + handleDockClose/Lock/PopOut/Pin/ReloadAction 6 373 command execution + effect ordering → the façade

This leaf is now the presentation row only: six methods, ~226 LOC → projection/HeaderActionPolicy.mjs. The router and the five command handlers stay in Workspace — they are the mutation boundary and the effect ordering, which is what the façade is for.

Whether the command handlers later become a cohesive command machine under interaction/ (the KeyboardCommands.mjs precedent) is a separate, deliberate decision and explicitly not this leaf. Filing it now would be the same over-scoping in a new folder.

The Fix

Extract the policy half to src/dashboard/dock/projection/HeaderActionPolicy.mjs. Membership stays where it already lives; only the workspace-state-reading policy moves.

projection/ is the right folder: the cluster's output is a projected action row, and projected action rows are already projection/LayoutAdapter.mjs's subject (:1074–1129 build, :1224 emit, :1295 listener) — the artifact @neo-fable named when correcting my owed-read list.

The 12/21 split is the leaf boundary. The nine methods that do not read workspace-only state stay put; a PR that moves all 21 has not found the seam, it has moved the file.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
projection/HeaderActionPolicy.mjs D#18150 OQ2 [RESOLVED_TO_AC] owns action-enablement policy and the state it reads — class docblock 12 methods / 609 LOC measured
toolbar.Base#getActionItems, tab.header.Toolbar#isTabButton shipped source unchanged — membership is already owned — — OQ2 measurement
Workspace header-action policy the Ownership test deleted, not forwarded none — zero-backcompat ruling — method/config counts fall in-PR

Acceptance Criteria

  • The six presentation methods and the state they read move to projection/HeaderActionPolicy.mjs. The PR body names each method that stays with the row it belongs to — command execution or effect ordering — rather than only the count.
  • Workspace.mjs retains no policy method or its state — no forwarding wrappers; method and config/state counts fall in this PR with the delta recorded.
  • Ownership test, Stage 0 = yes → all four questions answered, question 4 with a cross-concern call count before and after. This is the leaf where question 4 most plausibly fails; if the count rises, say so and reshape rather than shipping the wrapper.
  • Membership owners are untouched: no change to toolbar.Base#getActionItems or tab.header.Toolbar#isTabButton.
  • The façade keeps its mutation boundary: onDockHeaderAction and the five handleDock*Action handlers remain in Workspace. A PR that also moves them has re-created the cross-concern collaborator this narrowing exists to prevent.
  • Projected action rows stay projection-constant — per-item state must never vary the actions array (the invariant Workspace#afterSetMounted's docblock already states).
  • The engine's default action rail (#18014) and focus-gated reveal (#17874/PR #17875) behave identically before and after, witnessed on the dock example.

Out of Scope

  • Binding header state to Group provider state. That is the syncDock* re-derivation cut and it is gated on #18303's Group; this leaf moves the policy to a vehicle, it does not change where the state comes from.
  • The remaining 9 non-policy methods of the 21.
  • Action design — no action is added, removed or reordered; the frozen order stands.

Avoided Traps

  • Moving all 21 methods. The 12/21 measurement is the seam; moving the rest is relocation.
  • Dissolving policy into toolbar.Base#syncActions. Keyed-by-action-id is right for direct actions assignments and changes nothing in this census — @neo-fable's warning: do not sell it as the churn fix.
  • Bundling the Group-provider binding into this PR. It is separately gated; bundling makes neither half cleanly Resolves-able.

Related

#18304 (epic) · D#18150 OQ2 · #18303 (owns the Group the follow-on binding needs) · projection/LayoutAdapter.mjs:1074–1129, :1224, :1295 · #18014, #17874


Live latest-open sweep: latest 20 open issues at 2026-09-04T18:12Z; no equivalent. A2A claim sweep: no claim on this surface. Own-assignment sweep: 12 open, none on the header cluster. Memory Core sweep: semantic recall serving stale 2025 hits today — recorded as a null, not a clean sweep.

Decision Record impact: none — ADR 0029 untouched; this is placement within the existing anatomy.

Origin Session ID: b983b2a8-18bc-4dd0-a63f-23380dc3a49d