Context
The Fleet cockpit resolves two auto-hidden rail panes with module: () => import(...) (define-agent, wake routes). Revealing either from its rail tab opens an empty overlay (neomjs/neo-agent-institution#74). PR neomjs/neo-agent-institution#77 worked around it with a static import; the operator rejected that shape on 2026-09-02: a rail must lazy-load exactly like a tab container — no active item at startup, the module loaded on activation — because an eagerly imported pane pays class loading, construction, initAsync and any autoLoad store request at app boot (a buffered grid inside a never-opened pane is the canonical cost).
Observed (unit reproducer on dev@8c86f4478f, red-first): Rail#syncRevealPane → slot.add({...resolved}) → container.Base#insert → TypeError: item.createVdomReference is not a function at src/container/Base.mjs:692. createItem parks a function-module item as a plain object with vdom.removeDom: true (src/container/Base.mjs:442–462); createItems accepts that at construction because a card layout resolves it on activation (src/layout/Card.mjs:111, loadModule at :197), while insert() assumes an instance. Inferred: the throw escapes the reveal transition after the overlay became visible, so the consumer sees an empty slot rather than an error.
Live latest-open sweep: latest 20 open issues read 2026-09-02T08:52Z, none equivalent (the auto-hide leaves #13280 / #13158 predate lazy rail items). A2A: no competing lane-claim in the window. KB ticket sweep: no match.
The Problem
Lazy module functions are a card-layout capability: layout.Card#afterSetActiveIndex loads the active item's module and replaces the parked object (form.Container#loadModules delegates to parent.layout.loadModule for the same reason). A rail is the auto-hide analog of a tab strip with no active tab — reveal IS activation — but the reveal path materializes the pane through a plain container.Base slot (RevealOverlay#paneSlot) that has no loader. Nothing on that path resolves the function, so a lazy rail item can never render, and the failure is a throw inside a state-machine transition rather than a visible error.
The Architectural Reality
src/dashboard/dock/interaction/Rail.mjs:757 syncRevealPane: the resolver cascade live instance → config → blueprint → placeholder; a config is added with slot.add({...resolved}) (:794) and cached in revealPaneCache.
src/dashboard/dock/interaction/RevealOverlay.mjs paneSlot: a plain Neo.container.Base (items: []), pane-blind by design.
src/container/Base.mjs:442 lazy detection (module && !module.isClass && Neo.isFunction(module)), :459–462 the parking, :692 insert() calling createVdomReference() on whatever createItem returned.
src/layout/Card.mjs:197 loadModule: await module() → .default, sets className / module, deletes the parked vdom, creates the instance, re-seats the vdom reference, fires cardLoaded.
- ADR 0029 §2.6 row 1: a resolved instance is "moved / re-parented, never destroyed"; §2.7: "Click a rail tab → transient reveal: the pane renders as an overlay". A lazy item has no instance until its first reveal — creating it there is first materialization, not recreation.
- Consumer:
apps/agentos/view/fleet/cockpit/Container.mjs resolveDockItemConfig — define-agent (lazy on dev before PR #77) and wakeRoutes (module: () => import('../wake/Container.mjs')), both auto-hidden rail tools.
The Fix
In Rail#syncRevealPane, a resolved config whose module is a function (Neo.typeOf(module) === 'Function') is loaded before it is added: a protected async loadRevealPane(itemId, config) awaits config.module(), takes .default, and — if the rail is alive and revealOverlay.revealPaneItemId still names itemId — adds {...config, module} to the slot, caches the instance in revealPaneCache and runs the lock sync as for a synchronous pane. A reveal dismissed before the import settles adds nothing; the next reveal resolves again (the module registry makes the second import immediate). Non-lazy configs, live instances, blueprints and placeholders keep the synchronous path; syncRevealPane itself stays synchronous. The JSDoc names the tab-card parallel.
The failure route (amended 2026-09-02 on the reviewer's reproduction, PR #18063 Round 1): a rejected import must clear the lease (else the ??= short-circuit disables the item for the session and the rejection goes unhandled), show the adapter's recoverable placeholder in the still-open reveal — uncached, so the next reveal retries the import — and a rail destroyed while the import is in flight settles the load to null: core.Base#destroy deletes every own property, so the post-await guard reads the lease map optionally.
ADR 0029 §2.6: one amendment sentence under the "moved, never re-instantiated" row — a lazy item's first reveal creates its instance (first materialization); the never-recreate rule governs instances that exist.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
A resolveComponentRef / resolvePane config whose module is a function, revealed through a rail |
layout.Card#loadModule (src/layout/Card.mjs:197); container.Base#createItem lazy detection (:442) |
loaded on first reveal; the instance is added to the pane slot and cached per item |
until the import settles the slot is empty for that tick; a dismissed reveal loads nothing; a REJECTED import clears the lease, the open reveal shows the recoverable placeholder (uncached), the next reveal retries; a rail destroyed mid-load settles the load to null |
Rail#syncRevealPane + loadRevealPane JSDoc; ADR 0029 §2.6 amendment |
unit arms AC-1 / AC-2 / AC-7 red-first; component arm AC-3 |
Decision Record impact: amends ADR 0029 §2.6 (one sentence; operator ruling 2026-09-02).
Acceptance Criteria
Out of Scope
- A generic lazy loader on
container.Base#insert: a parked item in a container without a card layout can never be activated, so parking there would trade a throw for an invisible item.
- The blueprint path (JSON, no functions) and hover-reveal timing.
- The institution PR itself (AC-6 is the consumer row).
Avoided Traps
- Static import in the consumer (the PR #77 shape): the pane's module graph joins app boot; a heavy pane pays construction +
initAsync + store autoLoad before first paint — exactly what lazy modules exist to avoid. Rejected by the operator 2026-09-02.
- Placeholder-then-swap in the slot: two commits per reveal and a cache entry to replace; the card layout adds nothing until the module is loaded — keep parity.
- Making
syncRevealPane async: its callers are synchronous state-machine transitions; only the lazy branch defers.
- Caching the failure placeholder: a cached placeholder would be re-parented on the next reveal and the import never retried — the placeholder is transient by design.
Related
- neomjs/neo-agent-institution#74 (the symptom), neomjs/neo-agent-institution#77 (the rejected static-import shape, to be reworked into the pin bump),
#13280 (the auto-hide interaction layer), #18039 (the rail's reveal-pane cache and release paths), ADR 0029 §2.6 / §2.7.
Structure-map gate (§1c): N/A — dashboard/dock engine surface, no ai/ touch. Structural pre-flight: no new src file; the component fixture lifts the dock-maximize sibling.
Origin Session ID: 91f83b9c-df95-4f72-a68f-d33f470792ac
Retrieval Hint: "dock rail lazy module reveal loadRevealPane card layout activation"
📜 Clio
Context
The Fleet cockpit resolves two auto-hidden rail panes with
module: () => import(...)(define-agent, wake routes). Revealing either from its rail tab opens an empty overlay (neomjs/neo-agent-institution#74). PR neomjs/neo-agent-institution#77 worked around it with a static import; the operator rejected that shape on 2026-09-02: a rail must lazy-load exactly like a tab container — no active item at startup, the module loaded on activation — because an eagerly imported pane pays class loading, construction,initAsyncand anyautoLoadstore request at app boot (a buffered grid inside a never-opened pane is the canonical cost).Observed (unit reproducer on
dev@8c86f4478f, red-first):Rail#syncRevealPane→slot.add({...resolved})→container.Base#insert→TypeError: item.createVdomReference is not a functionatsrc/container/Base.mjs:692.createItemparks a function-module item as a plain object withvdom.removeDom: true(src/container/Base.mjs:442–462);createItemsaccepts that at construction because a card layout resolves it on activation (src/layout/Card.mjs:111,loadModuleat:197), whileinsert()assumes an instance. Inferred: the throw escapes the reveal transition after the overlay became visible, so the consumer sees an empty slot rather than an error.Live latest-open sweep: latest 20 open issues read 2026-09-02T08:52Z, none equivalent (the auto-hide leaves
#13280/#13158predate lazy rail items). A2A: no competing lane-claim in the window. KB ticket sweep: no match.The Problem
Lazy
modulefunctions are a card-layout capability:layout.Card#afterSetActiveIndexloads the active item's module and replaces the parked object (form.Container#loadModulesdelegates toparent.layout.loadModulefor the same reason). A rail is the auto-hide analog of a tab strip with no active tab — reveal IS activation — but the reveal path materializes the pane through a plaincontainer.Baseslot (RevealOverlay#paneSlot) that has no loader. Nothing on that path resolves the function, so a lazy rail item can never render, and the failure is a throw inside a state-machine transition rather than a visible error.The Architectural Reality
src/dashboard/dock/interaction/Rail.mjs:757syncRevealPane: the resolver cascade live instance → config → blueprint → placeholder; a config is added withslot.add({...resolved})(:794) and cached inrevealPaneCache.src/dashboard/dock/interaction/RevealOverlay.mjspaneSlot: a plainNeo.container.Base(items: []), pane-blind by design.src/container/Base.mjs:442lazy detection (module && !module.isClass && Neo.isFunction(module)),:459–462the parking,:692insert()callingcreateVdomReference()on whatevercreateItemreturned.src/layout/Card.mjs:197loadModule:await module()→.default, setsclassName/module, deletes the parkedvdom, creates the instance, re-seats the vdom reference, firescardLoaded.apps/agentos/view/fleet/cockpit/Container.mjsresolveDockItemConfig—define-agent(lazy on dev before PR #77) andwakeRoutes(module: () => import('../wake/Container.mjs')), both auto-hidden rail tools.The Fix
In
Rail#syncRevealPane, a resolved config whosemoduleis a function (Neo.typeOf(module) === 'Function') is loaded before it is added: a protectedasync loadRevealPane(itemId, config)awaitsconfig.module(), takes.default, and — if the rail is alive andrevealOverlay.revealPaneItemIdstill namesitemId— adds{...config, module}to the slot, caches the instance inrevealPaneCacheand runs the lock sync as for a synchronous pane. A reveal dismissed before the import settles adds nothing; the next reveal resolves again (the module registry makes the second import immediate). Non-lazy configs, live instances, blueprints and placeholders keep the synchronous path;syncRevealPaneitself stays synchronous. The JSDoc names the tab-card parallel.The failure route (amended 2026-09-02 on the reviewer's reproduction, PR #18063 Round 1): a rejected import must clear the lease (else the
??=short-circuit disables the item for the session and the rejection goes unhandled), show the adapter's recoverable placeholder in the still-open reveal — uncached, so the next reveal retries the import — and a rail destroyed while the import is in flight settles the load tonull:core.Base#destroydeletes every own property, so the post-await guard reads the lease map optionally.ADR 0029 §2.6: one amendment sentence under the "moved, never re-instantiated" row — a lazy item's first reveal creates its instance (first materialization); the never-recreate rule governs instances that exist.
Contract Ledger
resolveComponentRef/resolvePaneconfig whosemoduleis a function, revealed through a raillayout.Card#loadModule(src/layout/Card.mjs:197);container.Base#createItemlazy detection (:442)nullRail#syncRevealPane+loadRevealPaneJSDoc; ADR 0029 §2.6 amendmentDecision Record impact: amends ADR 0029 §2.6 (one sentence; operator ruling 2026-09-02).
Acceptance Criteria
test/playwright/unit/dashboard/DockRail.spec.mjs: a resolver config with a functionmodulematerializes on reveal — the slot holds a Neo instance of the loaded class with the config applied, cached inrevealPaneCache; the arm is red ondev(thecreateVdomReferencethrow).test/playwright/component/dashboard/, on a newapps/dock-lazy-railfixture lifted from thedock-maximizesibling): the lazy pane's module is not loaded at boot (a module-scope witness readable through the workspace) and is loaded and rendered inside the visible reveal overlay after the rail-tab click.dashboard/and the dock component specs green; the pin path is unchanged (a pinned lazy item enters the tab flow through the card layout's own load).module: () => import(...)for define-agent (wake routes stays lazy) and bumps the engine pin; the define-agent reveal renders the form from the rail tab and from the bootstrap CTA.nullwithout a throw.Out of Scope
container.Base#insert: a parked item in a container without a card layout can never be activated, so parking there would trade a throw for an invisible item.Avoided Traps
initAsync+ storeautoLoadbefore first paint — exactly what lazy modules exist to avoid. Rejected by the operator 2026-09-02.syncRevealPaneasync: its callers are synchronous state-machine transitions; only the lazy branch defers.Related
#13280(the auto-hide interaction layer),#18039(the rail's reveal-pane cache and release paths), ADR 0029 §2.6 / §2.7.Structure-map gate (§1c): N/A — dashboard/dock engine surface, no
ai/touch. Structural pre-flight: no newsrcfile; the component fixture lifts thedock-maximizesibling.Origin Session ID: 91f83b9c-df95-4f72-a68f-d33f470792ac
Retrieval Hint: "dock rail lazy module reveal loadRevealPane card layout activation"
📜 Clio