#18460's Contract Ledger row 1 (View interaction) requires "declarative wiring; no view-to-controller forwarding network". Measuring that row's population found five dispatch sites in apps/workstation/view/, and exactly one of them inverts both directions at once. That one is this leaf; the other four are a different shape and are enumerated below rather than folded in.
#18460 is labelled enhancement, so by ticket-create-workflow §4 it must be one-PR-resolvable — its five ACs span view interaction, product policy, native effects and a physical line ceiling, which it is not. #18533 (row: park/re-show) was routed under #18304 rather than under #18460 because it was engine work. This leaf is the app-side counterpart, filed so a PR can carry a truthful Resolves instead of closing four unmet ACs.
The Problem
apps/workstation/view/Workspace.mjs builds its own child by calling into its controller:
WorkspaceController#createTopologyBar() returns a toolbar config. Two directions are wrong in one expression: a controller authoring view structure, and a view reaching into its controller for a child it owns. The four remaining sites are single-direction reach-through (Viewport.mjs:59, :70, :151 addressing another component's controller; Workspace.mjs:795 a saveTopology facade the Neural Link and Viewport.mjs:153 both call as a shipped surface) — none of them has a controller producing view config, and removing the facade would break WorkstationTopologyGroupsNL.spec.mjs:229. They are the larger, riskier half and are explicitly out of scope here.
There is a second consequence, which is the reason this is not cosmetic. Because the bar is built once as a config literal, its per-participant recovery buttons are frozen at construction. getPopupStates() grows when a pane tears out (Workspace.mjs:2140) and shrinks when a popup host retires (PopupWorkspace.mjs:154), and the bar cannot follow either.
The Architectural Reality
A view controller is declared through the component's controller config and reaches into the view: src/controller/Component.mjs:117getReference() resolves via component.down({reference}), needing no mount.
src/controller/Component.mjs:44 binds onComponentConstructed() to the host's constructed event, which src/core/Base.mjs:820 fires after isConstructed is set — i.e. after the view's own construct() has added its children. Its docblock names exactly this use: "in case you need references". Eight in-repo controllers already override it, including the sibling apps/workstation/view/TourController.mjs:50.
Declarative handler names resolve through the configured controller chain: src/util/Function.mjs:158resolveCallback sends a string handler to scope.getController?.()?.getHandlerScope(fn, null). Nothing needs to close over the controller instance.
apps/portal/view/home/FooterContainer.mjs:26 (configured controller) with :40 (referenced child) is the executed precedent #18460 cites.
src/dashboard/dock/window/WorkspaceSet.mjs exposes ids() and no change signal, so a live-membership refresh has no event to hang on today. That is an engine seam, and #18460's Out of Scope forbids building it here.
The Fix
The view declares the toolbar inline: reference: 'topology-toolbar', the two authored buttons with declarative handler names, and the Group's undo/redo actions with their bind formatters reading me.topologyGroupId directly.
WorkspaceController#createTopologyBar() is replaced by syncTopologyBar(), which reaches the bar through getReference() and replaces only the derived tail after static authoredTopologyButtonCount, so a resync cannot destroy what the view authored.
Two declarative handlers (onOpenTopologyWorkspace, onMountTopologyWorkspace) read the addressed participant off data.component.workspaceKey.
onComponentConstructed() performs the single population, giving behaviour parity with the config literal.
Contract Ledger Matrix
Surface
Authority
Required outcome
Refusal/recovery
Evidence
The topology bar's structure
The view, through its own items/actions config
Declared in apps/workstation/view/Workspace.mjs; no controller method returns view config
Ordinary component disposal
A prototype-call witness reading reference, the authored handler names, and the item count
Its per-participant tail
WorkspaceController#syncTopologyBar via src/controller/Component.mjs:117
Only items after authoredTopologyButtonCount are replaced
Returns false on an unreachable or destroyed bar; never assumes it synced
A real toolbar.Base under the controller: populate, resync to empty, then destroy
The same witness asserts the bar is already filled before any explicit call
Handler routing
src/util/Function.mjs:158
String names only; no captured controller
Unresolved name is inert, as for any Neo handler
The declared names asserted against the controller's methods
Acceptance Criteria
apps/workstation/view/Workspace.mjs declares the topology bar; grep -n 'getController()' apps/workstation/view/Workspace.mjs returns no site that consumes view config from the controller. The PR reports the full before/after dispatch-site population, not just the one removed.
The controller fills the bar through getReference() and cannot destroy the view-authored head. A witness populates, resyncs after the participant set empties, and asserts the authored two survive.
The lifecycle wiring itself is witnessed, not just the method it calls: an arm that would go red if onComponentConstructed() stopped calling syncTopologyBar().
Behaviour parity is claimed and measured — the bar is populated exactly once, as today. The stale-after-tear-out limitation is reported as unchanged, with the missing WorkspaceSet signal named, and is not silently fixed here.
Workstation's 43 unit witnesses and the full unit tier stay green at current head; the one stale caller (test/playwright/unit/apps/workstation/WorkspaceController.spec.mjs:128) moves to the surface that now owns it, and the PR says which arms moved and why.
Mutation controls: renaming the view's reference, removing the lifecycle call, and replacing the whole item list instead of its tail each redden a named arm, and each arm is named in the PR.
Out of Scope
The four remaining view-to-controller dispatch sites (Viewport.mjs:59 · :70 · :151 and the Workspace.mjssaveTopology facade) — cross-component reach-through and a shipped Neural Link surface, both larger shapes with their own callers. A membership-change signal on WorkspaceSet. Any change to what the bar's buttons do, to Group/history semantics, or to #18460's other Ledger rows.
Avoided Traps
No ritual getReference() added to legitimize a role: the controller genuinely needs the child it did not declare. No new forwarding facade — one controller method is removed and one is added, both with real work. No refresh path wired to a signal that does not exist, which would be a new engine seam wearing an app-code disguise.
Decision Record impact
aligned-with ADR 0029 — no engine host, Group, projection or native-window contract is touched.
Sweeps
(i) Live latest-open: latest 20 open issues read at 2026-09-09T15:33:40Z (created-desc); no equivalent found. Nearest: #18461 (composition entrypoint), #18474 (declarative arrangement) — different surfaces.
(ii) A2A in-flight claims:list_messages({status:'all', limit:30}); open claims are #18478 (@neo-opus-grace) and #18479 (@neo-opus-ada). No claim on this surface.
(iii) Memory Core rationale:query_raw_memories on the problem's nouns (controller-authored view structure, view dispatching for its own child) returned four sessions, none deciding this row.
Context
#18460's Contract Ledger row 1 (View interaction) requires "declarative wiring; no view-to-controller forwarding network". Measuring that row's population found five dispatch sites in
apps/workstation/view/, and exactly one of them inverts both directions at once. That one is this leaf; the other four are a different shape and are enumerated below rather than folded in.#18460 is labelled
enhancement, so byticket-create-workflow §4it must be one-PR-resolvable — its five ACs span view interaction, product policy, native effects and a physical line ceiling, which it is not. #18533 (row: park/re-show) was routed under #18304 rather than under #18460 because it was engine work. This leaf is the app-side counterpart, filed so a PR can carry a truthfulResolvesinstead of closing four unmet ACs.The Problem
apps/workstation/view/Workspace.mjsbuilds its own child by calling into its controller:me.add([{module: TourToolbar}, me.createStatusBar(), me.getController().createTopologyBar(), { ... }]);WorkspaceController#createTopologyBar()returns a toolbar config. Two directions are wrong in one expression: a controller authoring view structure, and a view reaching into its controller for a child it owns. The four remaining sites are single-direction reach-through (Viewport.mjs:59,:70,:151addressing another component's controller;Workspace.mjs:795asaveTopologyfacade the Neural Link andViewport.mjs:153both call as a shipped surface) — none of them has a controller producing view config, and removing the facade would breakWorkstationTopologyGroupsNL.spec.mjs:229. They are the larger, riskier half and are explicitly out of scope here.There is a second consequence, which is the reason this is not cosmetic. Because the bar is built once as a config literal, its per-participant recovery buttons are frozen at construction.
getPopupStates()grows when a pane tears out (Workspace.mjs:2140) and shrinks when a popup host retires (PopupWorkspace.mjs:154), and the bar cannot follow either.The Architectural Reality
controllerconfig and reaches into the view:src/controller/Component.mjs:117getReference()resolves viacomponent.down({reference}), needing no mount.src/controller/Component.mjs:44bindsonComponentConstructed()to the host'sconstructedevent, whichsrc/core/Base.mjs:820fires afterisConstructedis set — i.e. after the view's ownconstruct()has added its children. Its docblock names exactly this use: "in case you need references". Eight in-repo controllers already override it, including the siblingapps/workstation/view/TourController.mjs:50.src/util/Function.mjs:158resolveCallbacksends a string handler toscope.getController?.()?.getHandlerScope(fn, null). Nothing needs to close over the controller instance.apps/portal/view/home/FooterContainer.mjs:26(configured controller) with:40(referenced child) is the executed precedent #18460 cites.src/dashboard/dock/window/WorkspaceSet.mjsexposesids()and no change signal, so a live-membership refresh has no event to hang on today. That is an engine seam, and #18460's Out of Scope forbids building it here.The Fix
reference: 'topology-toolbar', the two authored buttons with declarative handler names, and the Group's undo/redoactionswith theirbindformatters readingme.topologyGroupIddirectly.WorkspaceController#createTopologyBar()is replaced bysyncTopologyBar(), which reaches the bar throughgetReference()and replaces only the derived tail afterstatic authoredTopologyButtonCount, so a resync cannot destroy what the view authored.onOpenTopologyWorkspace,onMountTopologyWorkspace) read the addressed participant offdata.component.workspaceKey.onComponentConstructed()performs the single population, giving behaviour parity with the config literal.Contract Ledger Matrix
items/actionsconfigapps/workstation/view/Workspace.mjs; no controller method returns view configreference, the authored handler names, and the item countWorkspaceController#syncTopologyBarviasrc/controller/Component.mjs:117authoredTopologyButtonCountare replacedfalseon an unreachable or destroyed bar; never assumes it syncedtoolbar.Baseunder the controller: populate, resync to empty, then destroysrc/controller/Component.mjs:44→src/core/Base.mjs:820onComponentConstructedsrc/util/Function.mjs:158Acceptance Criteria
apps/workstation/view/Workspace.mjsdeclares the topology bar;grep -n 'getController()' apps/workstation/view/Workspace.mjsreturns no site that consumes view config from the controller. The PR reports the full before/after dispatch-site population, not just the one removed.getReference()and cannot destroy the view-authored head. A witness populates, resyncs after the participant set empties, and asserts the authored two survive.onComponentConstructed()stopped callingsyncTopologyBar().WorkspaceSetsignal named, and is not silently fixed here.test/playwright/unit/apps/workstation/WorkspaceController.spec.mjs:128) moves to the surface that now owns it, and the PR says which arms moved and why.reference, removing the lifecycle call, and replacing the whole item list instead of its tail each redden a named arm, and each arm is named in the PR.Out of Scope
The four remaining view-to-controller dispatch sites (
Viewport.mjs:59·:70·:151and theWorkspace.mjssaveTopologyfacade) — cross-component reach-through and a shipped Neural Link surface, both larger shapes with their own callers. A membership-change signal onWorkspaceSet. Any change to what the bar's buttons do, to Group/history semantics, or to #18460's other Ledger rows.Avoided Traps
No ritual
getReference()added to legitimize a role: the controller genuinely needs the child it did not declare. No new forwarding facade — one controller method is removed and one is added, both with real work. No refresh path wired to a signal that does not exist, which would be a new engine seam wearing an app-code disguise.Decision Record impact
aligned-with ADR 0029— no engine host, Group, projection or native-window contract is touched.Sweeps
list_messages({status:'all', limit:30}); open claims are #18478 (@neo-opus-grace) and #18479 (@neo-opus-ada). No claim on this surface.query_raw_memorieson the problem's nouns (controller-authored view structure, view dispatching for its own child) returned four sessions, none deciding this row.Related
Parent: #18460 · Epic: #18456 · Related: #18304 · #18533 · PR #18542
Origin Session ID: 206b8400-7fe8-472e-8018-446e550b784b
Retrieval Hint: "Workstation topology bar authored by its controller, view-to-controller dispatch population, onComponentConstructed getReference"