LearnNewsExamplesServices
Frontmatter
id18562
titleWorkstation's Viewport reaches past a component into its controller
stateClosed
labels
enhancementdeveloper-experienceairefactoringdashboard
assigneesneo-opus-vega
createdAtSep 9, 2026, 9:13 PM
updatedAtSep 12, 2026, 6:57 PM
githubUrlhttps://github.com/neomjs/neo/issues/18562
authorneo-opus-vega
commentsCount2
parentIssue18460
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 12, 2026, 6:57 PM

Workstation's Viewport reaches past a component into its controller

Closed v13.2.0/archive-v13-2-0-chunk-20 enhancementdeveloper-experienceairefactoringdashboard
neo-opus-vega
neo-opus-vega commented on Sep 9, 2026, 9:13 PM

Context

#18460's Ledger row 1 (View interaction) required "declarative wiring; no view-to-controller forwarding network". Measuring that row found five dispatch sites. #18545 removed the one that inverted both directions at once — a controller authoring view config which the view then pulled in for its own child. This leaf removes the remaining three and closes #18460's AC-2 completely, so that AC stops being partially met across two PRs.

The Problem

apps/workstation/view/Viewport.mjs resolves, through the Transaction Group, which workspace owns its window — then reaches past that component into its controller:

await workspace.rootWorkspace.getController().mountTopologyWorkspace(binding.workspaceKey, me)  // :59
await root.getController().mountTopologyWorkspace(binding.workspaceKey, me)                     // :70
workspace.getController().attachTopologyLibrary();                                              // :151

This is not the shape #18545 fixed. The Viewport is not finishing its own child; it is using another component's controller as that component's public API. The cost is a coupling nobody declared: Viewport now depends on the controller class's method names, so swapping or subclassing WorkspaceController breaks a file that never mentions it. getController() is a legitimate engine method — the defect is that it is being used as an API boundary between two components.

The app already answers this for the same concern one line away. Workspace#saveTopology (:791) is a component method delegating to the controller, and Viewport.mjs:153 calls it correctly — await workspace.saveTopology(). Three sites bypass the pattern the fourth uses.

The Architectural Reality

  • src/component/Base.mjs:1399 resolves getController(); it is a real engine surface and this ticket does not touch it. What is at issue is a consumer using it to address another component.
  • The controller genuinely owns the implementation: mountTopologyWorkspace (WorkspaceController:185) and attachTopologyLibrary (:95) orchestrate Group participants and the topology library. Moving the bodies onto the component would put orchestration in a view — the inverse error.
  • Workspace#saveTopology:791 → WorkspaceController#saveTopology:70 is the executed precedent in this exact file pair, and it carries a shipped external caller: WorkstationTopologyGroupsNL.spec.mjs:229 reaches it through app.callMethod, which addresses components, not controllers. Anything the Neural Link must reach has to be a component method.
  • apps/portal/view/home/FooterContainer.mjs remains the MVC precedent #18460 cites for the intra-component direction; it says nothing about cross-component access, which is why this row needs its own reading.

The Fix

⚠️ Prescription corrected 2026-09-09. The original prescription — publish the two operations as component methods delegating to the controller — was vetoed by @tobiu and is withdrawn. PR #18563 delivered it and is closed.

The rule that governs this, from @tobiu:

controllers may mutate views. views must NEVER reach into their controllers. SSOT paradigm in a way. maybe better: ownership.

Under it the original prescription is the anti-pattern it meant to fix: a delegate on the view is still a view reaching into its controller, and it adds a hop rather than removing one. apps/portal — the reference app for apps/** — has zero such pass-throughs (@neo-opus-grace, measured at dev).

The correct shape. Workstation.view.Viewport has no controller at all, while carrying ~140 lines of boot orchestration inside the view: URL params, Group binding resolution, Workspace creation, the cold-hydrate write, error handling. That belongs on a ViewportController, as at apps/portal/view/Viewport.mjs:31. Once it moves, the three getController() calls happen from a controller, which the rule permits, and the view becomes declarative.

Implemented, and measured to break the boot — this is not a mechanical move. Viewport 185 → 51 lines, ViewportController 176 lines, workstation unit tier 97/97 green, and yet:

  • pristine dev: .workstation-dock-host count 1
  • with the move: count 0

The controller does run — instrumented through Neo.Main.log, the trace reads VC:enter → VC:url-ok → VC:params undefined — then dies silently in the default boot branch. Silent because an async rejection inside onComponentConstructed is an unhandled rejection, and unhandledrejection is hooked nowhere.

Cleared while diagnosing, so nobody re-chases it: onComponentConstructed fires correctly for ordinary components. static observable is a shortcut for the Observable mixin and component.Base has it; a probe on a plain container.Base with a controller gives RAN=true, STATIC_OBSERVABLE=true. The seam is sound.

The shape, from prior art rather than derivation. neomjs/neo-agent-institution has already built this (@neo-fable-clio): the booting window does not resolve its owner — the owner adopts it on onWindowConnect, matching identity declared in the URL, behind a state machine docked → opening → connected → windowed → reattaching → docked with failed-blocked / failed-timeout edges and commit-or-neither rollback (apps/agentos/view/fleet/cockpit/VesselContainer.mjs:81, :654).

Workstation already owns the pieces: Workspace#registerVesselWorkspaceTarget (:973) sets state.renderTarget = app.mainView and is called from three sites, manager.Window publishes onWindowConnect (:174), and dashboard/Container.mjs:329 implements it. The cold-boot path simply bypasses all of it and resolves imperatively from the view.

⚠️ Staleness bound, measured — and it splits the prior art in two. The repo pins neo.mjs at 205bc52f8a (2026-09-04 15:01), 128 commits behind dev. src/manager/Transaction.mjs was created — not renamed — by ef9670621c (2026-09-05 15:03), "logical topology Groups bind windows across generations". So that work predates the entire Group layer by about a day: at the pin, src/manager/ contained no Transaction at all, and cross-window identity had only manager/Window. Since then: 6 commits on manager/Transaction, 3 on manager/Window, 22 on dashboard/dock/Workspace. Declarative pane arrangements (f62b7560d9) also landed after.

What transfers is the direction, not the identity mechanism.

  • Transfers: the arriving window announces and the owner adopts on connect. That inverts the dependency regardless of what holds identity, and it is what makes reparent and cold boot one path instead of two.
  • Transfers as requirements: the failure edges their machine names — a non-matching arrival that must be ignored rather than adopted, a blocked popup, an arrival that never comes, and rollback that is commit-or-neither. Those are real states whoever owns identity.
  • Does NOT transfer: matching identity from URL parameters, and hand-rolling the machine. That is what you do with no binding registry. Workstation has one — Transaction.findByWindow(windowId) yields the binding, getParticipant(groupId, workspaceKey) the owner — and the commit that introduced it says it binds windows across generations, which is precisely the cold-boot-after-reload case this ticket is about.
  • Does NOT transfer: their dock configuration, which is pre-declarative.

Not the carrier — with one correction. An earlier comment of mine proposed a Group-scoped state.Provider as the coordination channel; that is wrong, and the prior art parents no providers across windows. manager.Transaction's provider stays history state, and the announcement is onWindowConnect, not a datum.

But provider membership is still part of the answer, because runtime re-parenting works — measured: setting provider.parent live re-resolves every binding in the subtree against the new chain immediately, with no stale reads from the old one and no afterSetParent hook needed. So adoption can be two acts rather than one: mount the component and join my state hierarchy. That is a stronger tool than the construction-time parent this app already uses for PopupWorkspace, because it does not require the owner to have created the subtree.

⚠️ Unmeasured before anyone builds on it: stores across a re-parent, formula re-evaluation, and two-way bindings — whose setData closest-owner walk means a write could land in a different provider before and after. Detail and probe: D#18490.

Order: adopt on onWindowConnect for the cold-boot path first; the ViewportController move follows from it, because once the view stops orchestrating there is little left in onConstructed to move.

Contract Ledger Matrix

⚠️ Withdrawn with the prescription. Its rows described component-level entry points on Workspace that will not exist. A new ledger is owed only if the successor shape introduces a consumed surface; moving orchestration onto a ViewportController introduces none.

Acceptance Criteria

  • No view in apps/workstation/view/ reaches into a controller. grep -n 'getController()' apps/workstation/view/*.mjs returns only sites inside a controller, or none, and the PR reports the before/after population.
  • Workstation's default boot is proven, in an arm that runs without a Brain checkout: .workstation-dock-host renders. The move already broke this once while the whole unit tier stayed green, so a passing unit tier is explicitly not evidence for this AC.
  • Both non-default boot modes still work — ?popout=<id> and ?workspace=<key> — including the refusal branch rendering "This saved window is waiting for its original workspace."
  • Workspace#saveTopology's existing getController() call is dispositioned rather than ignored: same violation, predates this ticket, and reached by the Neural Link through app.callMethod. Fixed here, or the PR says why it is a separate leaf.
  • Mutation-proven: the boot arm reddens when the orchestration is disabled.

Out of Scope

Workspace#saveTopology itself, which is already correct. getController() as an engine surface. The event-contract alternative named in The Fix. #18460's other Ledger rows, and #18532's witness. Any change to what the two operations do.

Avoided Traps

No forwarding network: exactly two entry points are added, both replacing existing cross-component calls, and no method is wrapped that nothing outside calls. No moving orchestration onto the view to avoid the word "façade" — that is the inverse error and worse. No getController() left behind "temporarily". No weakening of the Viewport's refusal branch to make a witness simpler.

Decision Record impact

aligned-with ADR 0029 — no engine, Group, projection or native-window contract is touched.

Sweeps

  • (i) Live latest-open: latest 12 open read 2026-09-09T19:13:03Z (created-desc); no equivalent. Nearest are #18558 (topology bar undo/redo reach) and #18553, both different surfaces.
  • (ii) A2A in-flight claims: mailbox scanned this session; no claim on Viewport.mjs or the controller surface.
  • (iii) Memory Core: the view-controller direction rule and #18545's own measurement are the prior art; both re-read against merged ebb1f19937 before filing, which is what established that the remaining three are a different shape from the one #18545 fixed.
  • (iv) Own assignments: #18460 (parent), #18532, #17918, #16212 — only #18460 overlaps, as its parent.
  • Duplicate: Viewport getController mountTopologyWorkspace OR "view-to-controller" across all states returns #18460, #18456 and the closed #18545. Nothing open.

Related

Parent: #18460 · Epic: #18456 · Predecessor: #18545

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

Retrieval Hint: "Viewport reaches past a component into its controller, cross-component getController, Workstation AC-2 completion"