Skip to content

<objective> Build the composition root: one module that wires RuntimeConfig plus the real runtime adapters into a LifecycleController and a ToolDispatcher, exposing a RealmHandle with close().

Purpose: Deliver P5-D7 and P5-D15, the shared foundation plans 05-05 and 05-06 compose against, with no dependency on any other Phase 5 work. Output: Compose module plus its test file. </objective>

<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>

<context> @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/05-omp-plugin-mcp-and-acp-surfaces/05-CONTEXT.md </context>

<tasks>

<task type="tdd"> <name>Task 1: createRealmHandle with one shared LockTable (P5-D7, P5-D15)</name> <files>src/realm/compose.ts, test/realm/compose.test.ts</files> <read_first> - src/lifecycle/controller.ts lines 58-71 (LifecycleDeps: sessionId, policy, store, exec, bridge, clock, optional locks) - src/lifecycle/controller.ts lines 104-117 (snapshot, mutationGate; the members the dispatcher reads) - src/tools/dispatch.ts lines 52-68 (ToolDispatcherDeps: sessionId, boundRoot, policy, controller, locks, stateDir, runOpenShell) - src/runtime/config.ts lines 10-23 (RuntimeConfig keys) and the loadRuntimeConfig signature - src/core/policy.ts lines 27-37 (loadPolicyFile signature and defaults) - src/fs/root.ts (bindRoot signature for the workspaceRoot bind) - src/session/store.ts lines 27-34 (SessionStore constructor takes stateDir) - test/support/fake-execution.ts lines 37-45 (FakeExecutionService constructor surface: none needed, direct construction) - test/support/fake-bridge.ts lines 24-30 (FakeBridge construction and connect/close surface) </read_first> <behavior> - Test (OMP-01): createRealmHandle builds a handle whose controller snapshot starts unbound with reason "no workspace" and whose dispatcher answers status through the shared mapping - Test (OMP-01): the controller and the dispatcher hold the same LockTable instance (assert identity, not equality) - Test (OMP-05): a write through handle.dispatcher while non-active is refused in required mode and warns in advisory mode, proving the handle wired the mode gate - Test titles cite OMP-01 or OMP-05 </behavior> <action>Write the failing tests first per behavior, then implement src/realm/compose.ts per P5-D7: export a RealmHandle interface (controller: LifecycleController, dispatcher: ToolDispatcher, close(): Promise of the stop snapshot) and a createRealmHandle factory that takes the runtime input, a policy path, a session id, an ExecutionService, a BridgePort, a clock, and a runOpenShell runner; inside it loads RuntimeConfig via loadRuntimeConfig, loads Policy via loadPolicyFile, binds the workspace root via bindRoot, constructs one LockTable and passes that same instance to both LifecycleDeps.locks and ToolDispatcherDeps.locks (P5-D15), constructs SessionStore over the config stateDir, then the controller and the dispatcher. close() delegates to the existing controller stop path and is safe to call twice. The module reads no process.env; the caller supplies every adapter. Open the module with a header comment naming P5-D7 and P5-D15. The test doubles only at the adapter seam: FakeExecutionService for exec, FakeBridge for bridge, a fixed clock, and a stub runner; it asserts no process starts by checking the fake service created no resources. (Implements P5-D7, P5-D15.)</action> <acceptance_criteria> - test/realm/compose.test.ts asserts unbound start, LockTable identity across controller and dispatcher, and the mode-correct write gate in both modes - src/realm/compose.ts constructs exactly one LockTable and reads no process.env </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/realm/compose.test.js</automated> </verify> <done>Handle builds controller plus dispatcher over one lock table with both modes gated.</done> </task>

<task type="auto"> <name>Task 2: Single-owner precondition and frozen-surface pins (P5-D7)</name> <files>src/realm/compose.ts, test/realm/compose.test.ts</files> <read_first> - src/realm/compose.ts (from Task 1 of this plan; the factory whose doc comment gains the precondition) - src/session/paths.ts lines 26-36 (sessionsDirOf and controlSocketPath the single-daemon note references) - test/tools/no-bypass.test.ts lines 100-105 (title-cite style for structural assertions) </read_first> <action>Extend the Task 1 files without changing their wiring: document the single-owner precondition in the createRealmHandle doc comment (no LifecycleController or ToolDispatcher exists yet in the process for this state directory; one host daemon owns the state directory; cross-process enforcement belongs to Phase 6 per OQ-P4-06), and add structural assertions to test/realm/compose.test.ts that src/realm/compose.ts references ToolDispatcher and LifecycleController but references none of the D-99 guarded symbols directly (buildExecArgv, lockedWrite, readFileCapped, listDir, spawnOpenShellRunner) and names no OPENSHELL_BINARY literal, so the handle cannot become a second tool entry point. Test titles cite OMP-01. (Implements the P5-D7 precondition half and pins the D-88 single-entry invariant for the new module.)</action> <acceptance_criteria> - The factory doc comment states the single-owner precondition and names Phase 6 as the enforcer - The structural test pins that compose.ts reaches tools only through ToolDispatcher </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/realm/compose.test.js</automated> </verify> <done>Precondition documented and single-entry invariant pinned.</done> </task>

</tasks>

<threat_model>

Trust Boundaries ​

BoundaryDescription
runtime input -> loadRuntimeConfigUntrusted config crosses into enforcement; closed keys and absolute paths are checked at load
policy file -> loadPolicyFilePolicy text crosses into mode decisions; unreadable or invalid files fail closed per the loader

STRIDE Threat Register ​

Threat IDCategoryComponentSeverityDispositionMitigation Plan
T-05-01T (Tampering)src/realm/compose.tshighmitigateOne LockTable instance is shared by controller and dispatcher; the identity assertion proves no second table can split lock ownership (P5-D15)
T-05-02E (Elevation of Privilege)src/realm/compose.tshighmitigateAdapters arrive as arguments and no process.env is read, so the handle cannot smuggle host environment into the sandbox (AUD-02 carry)
</threat_model>

<verification> npx tsc -p tsconfig.json && node --test dist/test/realm/compose.test.js; grep test/realm/compose.test.ts for OMP-01 and OMP-05 in test titles. </verification>

<success_criteria> Handle builds controller plus dispatcher from RuntimeConfig with one shared lock table, both modes gated, no process started in tests. </success_criteria>

Artifacts this phase produces (contribution of this plan) ​

  • src/realm/compose.ts (new): exports createRealmHandle and the RealmHandle type (controller, dispatcher, idempotent close()) (P5-D7, P5-D15).
  • test/realm/compose.test.ts (new): OMP-01 wiring and identity cases, OMP-05 mode-gate cases, single-entry structural pins.

<output> Create .planning/phases/05-omp-plugin-mcp-and-acp-surfaces/05-01-SUMMARY.md when done </output>

AGPL-3.0-only. Copyright (C) 2026 Spectrum Web Co/swcstudiospace.