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
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
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#getActionItemsandtab.header.Toolbar#isTabButtondecide 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.
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 alreadyprojection/LayoutAdapter.mjs's subject (:1074–1129build,:1224emit,:1295listener) — 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
projection/HeaderActionPolicy.mjs[RESOLVED_TO_AC]toolbar.Base#getActionItems,tab.header.Toolbar#isTabButtonWorkspaceheader-action policyAcceptance Criteria
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.mjsretains no policy method or its state — no forwarding wrappers; method and config/state counts fall in this PR with the delta recorded.toolbar.Base#getActionItemsortab.header.Toolbar#isTabButton.onDockHeaderActionand the fivehandleDock*Actionhandlers remain inWorkspace. A PR that also moves them has re-created the cross-concern collaborator this narrowing exists to prevent.Workspace#afterSetMounted's docblock already states).Out of Scope
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.Avoided Traps
toolbar.Base#syncActions. Keyed-by-action-id is right for directactionsassignments and changes nothing in this census — @neo-fable's warning: do not sell it as the churn fix.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, #17874Live 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