Files
callstack__agent-device/docs/daemon-modularity-proposal.md
T
Michał Pierzchała ee12f4d5bc fix: publish replay refs after response projection (#1489)
* fix: publish replay refs after response projection

* fix: retain replay device lock through finalization

* fix: verify Apple text wait misses with snapshot

* fix: lock replay device before open effects

* fix: lock replay device before open preparation
2026-07-30 10:03:19 +02:00

48 KiB
Raw Permalink Blame History

Proposal: make daemon a serialized host, not the home of every runtime

Status: revised design context; implementation handoff: #1478

Measured at: e545544dfa85

Recommendation

Keep the daemon as the serialized application host:

  • transport and authentication;
  • request admission, cancellation, locks, and leases;
  • request-scoped provider binding;
  • session ownership and teardown;
  • diagnostics, artifacts, response projection, and finalization.

Move program semantics behind independent module APIs:

  • native .ad replay;
  • Maestro;
  • replay-test scheduling and reporting.

Keep script-publication lifecycle in a session-owned capability. Native replay owns planning and execution semantics, a pure internal codec owns canonical .ad syntax, and the session capability owns publication validation. Active publication, ordinary and repair close-time publication, repair commit, idle reap, and shutdown all consume live session state and must remain one transactional authority.

Connect those modules to daemon-owned capabilities through adapters created inside the already admitted and locked request scope. Do not give an engine DaemonRequest, DaemonInvokeFn, SessionStore, mutable SessionState, a provider handle, or a platform implementation.

The organizing rules are:

State crosses boundaries as values, authority crosses as capabilities, and both only narrow.

A boundary exists when its port has two implementations; otherwise it is indirection.

The durable communication, state-consistency, R7, and locality rules live in module-interface-principles.md. This proposal applies them to the daemon: immutable data travels down through façades, calls travel back through ports, and the daemon adapter closes each capability over one already admitted request. Control flow stays call-based; ADR 0018's journal is observation only, never an inter-module event bus.

Make logical boundaries and one-way imports work under src/ first. Consider private pnpm workspace packages only after a module no longer reaches back into root source and a measured package trigger exists. A package that imports ../../src/daemon is a directory with a package.json, not encapsulation.

Why change

The production graph is acyclic and passes the repository's layering gates. The problem is not spaghetti dependencies; it is weak information hiding and poor locality.

Production zone Files LOC
daemon-server 221 46,504
platforms 173 33,608
commands 110 16,468
compat 47 8,068
utils 72 7,278
contracts 83 6,756
core 32 6,526
cli 33 6,087
replay 22 4,339

Relevant slices:

Slice Files LOC
daemon session-replay* 23 5,361
native replay part of that slice 19 4,297
daemon replay-test runner 8 1,967
daemon Maestro integration 4 1,064
Maestro daemon adapter misplaced under compat 5 1,549
all Maestro production files under compat 46 8,041
Maestro engine/policy after removing daemon adapter files 41 about 6,492

The test locality cost is larger than the production table suggests:

  • 27 daemon replay test files: 12,855 physical lines;
  • 8 daemon replay-test files: 1,899 physical lines;
  • 34 Maestro test files: 6,831 physical lines;
  • session-replay-vars.test.ts alone is 2,299 lines.

The dependency graph has zero value cycles and zero ranked back-edges. At the same time:

  • daemon-server has 117 collapsed dependencies on platforms, including 80 value dependencies;
  • the dependency audit classified 88 daemon-server platform-condition branches, more than the platform tree itself, so dependency direction alone understates the coupling;
  • daemon-server has 22 dependencies on compat;
  • compat has 5 dependencies back into daemon-server;
  • daemon/types.ts is 508 lines with 155 production importers;
  • session-store.ts has 62 production importers;
  • SessionStore.get() returns a live mutable record whose aliasing is the persistence mechanism.

The largest value-plus-type strongly connected component is 102 files. Its cycles are type-only, so this is not a runtime correctness problem, but it is a locality problem. A fresh membership probe at the measured commit finds 30 daemon files in that component, but none of the session-replay*, session-test*, or compat/maestro engine files. Engine extraction therefore must not claim this reduction. Session-state and platform-contract work in Phases 3 and 5 is where the R9 baseline can shrink. In particular, SessionState has two direct type imports from concrete platform perf implementations, but only the Apple perf-xctrace.ts edge is load-bearing: removing it reduces the component from 102 files / 30 daemon files to 91 / 21. Removing only the Android perf.ts edge leaves 102 / 30 unchanged, and removing both produces the same 91 / 21 as Apple alone. The Android move is still ownership hygiene; it must not be credited with the SCC reduction. The replacement Apple resource handle must not recreate the upward edge.

Moving files without changing those interfaces would reduce a folder count while preserving the reasoning burden.

Architectural constraints from the ADRs

The migration must preserve these decisions:

  1. Providers remain below platform translation so provider integration tests exercise real platform behavior (ADR 0001).
  2. Platform helper reuse remains an optimization owned by the daemon/platform host; do not invent one universal cross-platform runner (ADR 0002).
  3. Daemon route and request policy remains private and distinct from public command metadata (ADR 0003).
  4. This is an implementation-only refactor for every supported surface: preserve the daemon RPC request/response wire and do not bump rpcProtocolVersion (ADR 0006). Phase 3 deliberately closes one accidental raw-RPC behavior—record stop with unsupported flags.saveScript—by rejecting or ignoring that flag at the daemon seam. That behavior is wire-observable but absent from the CLI, Node, and MCP contracts; pin the intended refusal before deleting its writer path rather than treating adversarial input as a supported command contract.
  5. Device/session locking and lease admission retain their current ordering (ADR 0007).
  6. Command identity remains derived from CommandDescriptor; in-process execute and cross-process invoke remain distinct (ADR 0008).
  7. Keep the consolidated Apple-family model and its AppleOS discriminant/leak guard (ADR 0009).
  8. Module failures preserve the ADR 0010 wire contract: code, message, hint, details, diagnosticId, logPath, and optional typed retriable/supportedOn signals.
  9. The guarantee matrix remains honest about every interaction path and fast path (ADR 0011).
  10. Native replay retains target verification, structured divergence, plan-digest-bound resume, agent-supervised repair, and no-clobber publication (ADR 0012).
  11. Follow the ADR 0013 precedent at platform seams: normalize once into a canonical contract and let adapters consume it rather than reinterpreting intent.
  12. Ref admission and synchronous expiry remain at the actual daemon side-effect seam, including a mutation that may have failed after changing the device (ADR 0014).
  13. Maestro remains a source-preserving typed engine over its own runtime port. It must not lower into native replay or share a replay VM with .ad (ADR 0015).
  14. Active publication computes sessionActive from the daemon store, keeps ordinary publication disjoint from repair state, and preserves the ARMED/ABORTED/PUBLISHED lifecycle (ADR 0016).
  15. Sensitive live values are parameterized before durable recording or publication (ADR 0017).
  16. If ADR 0018 proceeds, progress remains a separate transport output port rather than a generic journal event.

Designs considered

A. Folder reshuffle only

Move session-test* to src/replay/test, move Maestro files to src/maestro, and split the large handler directory.

This is useful as a migration step, but insufficient as the design. If the moved modules still accept DaemonRequest, SessionStore, and DaemonInvokeFn, contributors can cross every boundary exactly as before.

B. One universal ProgramRunner or shared effect VM

Put .ad and Maestro in one package and make both yield a common ProgramEffect union.

This makes the diagram small but the module shallow. .ad is a linear SessionAction format with target-v1 verification and repair publication. Maestro has structured control flow, scoped variables, script outputs, compatibility selector semantics, observation generations, and its own resume restrictions. Their common execution vocabulary would either:

  • encode the semantic cross-product;
  • become a generic daemon command trampoline; or
  • quietly erase behavior ADR 0015 made explicit.

Rejected. Share test-level outcomes and host capabilities, not interpreter semantics.

C. Big-bang workspace and universal DeviceGateway

Immediately create packages for daemon, replay, Maestro, every platform, providers, contracts, and UI trees. Replace the current platform/provider paths with one broad gateway.

This gives hard physical boundaries, but the current engines still import root kernel, selector, snapshot, request, and utility modules. Doing this first forces a root/package cycle or a broad foundation package. It also puts tsdown bundling, declaration generation, dynamic platform loading, and npm packaging on the critical path of a behavior-preserving refactor.

Rejected as the first move. A request-bound device binding is promising later, but should compose the existing Interactor and focused resource facets instead of replacing them with one tagged observe/interact/manage/resource union.

D. One Redux-like or request-transactional session store

Route every session mutation through actions/reducers and commit or roll back at request completion.

Rejected. ADR 0014 requires ref-frame expiry to commit synchronously immediately before an asynchronous device operation and to survive that operation's failure. End-of-request rollback would recreate the success-only-rollback bug. Repair/publication are staged protocols with failure-preserving tombstones, while actions/events are append-only streams; forcing all three consistency disciplines through reducers adds a shallow translation layer. The request lock, single-threaded runtime, capability-owned transitions, R7, and event audit trail already provide the useful guarantees.

E. Domain-first strangler; workspaces only when earned

Create small public façades around demonstrated domains, keep daemon-owned adapters at the outside, and tighten the import graph after each move. Once a module has no root back-edge, keep the logical module unless an independent build/consumer or an enforcement failure earns a package.

Recommended.

Target architecture

flowchart LR
  Surface["CLI / MCP / daemon client"] --> Host["Daemon host"]
  Host --> Scope["Locked request scope"]
  Scope --> TestAdapter["Replay-test daemon adapter"]
  Scope --> AdAdapter[".ad daemon adapter"]
  Scope --> MaestroAdapter["Maestro daemon adapter"]

  TestAdapter --> Test["Replay-test scheduler"]
  Test --> TestProgram["TestProgramExecutor"]
  TestProgram --> AdAdapter
  TestProgram --> MaestroAdapter

  AdAdapter --> Ad["Independent .ad replay engine"]
  MaestroAdapter --> Maestro["Independent Maestro module"]

  Ad --> AdRuntime["AdReplayRuntime"]
  Maestro --> MaestroPort["MaestroRuntimePort"]
  AdRuntime --> Scope
  MaestroPort --> Scope

  Scope --> Session["Locked session capabilities"]
  Session --> Publication["Session script publication"]
  Publication --> AdCodec["Internal canonical .ad codec"]
  Scope --> Device["Request-bound device binding"]
  Device --> Interactor["Interactor + focused resource facets"]
  Interactor --> Platforms["Platform plugins and providers"]

The daemon adapter closes over one admitted request. Engine APIs do not accept an arbitrary session name for mutation and cannot reacquire locks, switch provider scope, or address another session.

Use three explicit visibility levels:

Surface Visibility Allowed consumers
.ad engine façade and runtime types module-public through src/ad-replay/index.ts daemon .ad adapter
Maestro façade and runtime port module-public through src/maestro/index.ts daemon Maestro adapter
replay-test façade and host port module-public through src/replay/test/index.ts daemon replay-test adapter
canonical .ad codec repository-private leaf contract .ad engine and session script publication only
SessionScriptPublication, replay transaction, resource ledger daemon-private capabilities locked request and lifecycle coordinators
LockedSessionContext, DaemonRequest, provider handles daemon-private implementation daemon adapters and composition only
engine IR, parsers, mutable plan state implementation-private their owning engine

“Module-public” means the reviewed internal API other modules may import; it does not add an npm export or change the daemon wire. Imports into an internal/ directory fail the boundary gate.

Proposed module APIs

Native .ad replay

Prepare once, then execute an opaque plan:

export interface AdReplayEngine {
  prepare(input: AdReplayPrepareInput): Promise<PreparedAdReplay>;
  execute(
    prepared: PreparedAdReplay,
    input: AdReplayExecutionInput,
    runtime: AdReplayRuntime,
  ): Promise<AdReplayOutcome>;
}

PreparedAdReplay is opaque except for an immutable summary: source identity, plan digest, step count, effective metadata, and static target hint. AdReplayExecutionInput carries runtime values and an optional client-supplied resume claim:

export type AdResumeClaim = {
  from: number;
  planDigest: string;
};

prepare computes the digest from the freshly expanded plan; execute validates the claim against that exact prepared plan. There is no daemon checkpoint keyed by digest and no second parse between inspection and execution.

The engine receives a small earned runtime port:

export interface AdReplayRuntime {
  readonly signal: AbortSignal;

  executeStep(input: AdStepExecution): Promise<AdStepOutcome>;

  /**
   * Capture replay evidence and update operational observation state only.
   * The returned value may carry opaque, one-shot lineage evidence; it cannot
   * publish or replace client ref authority.
   */
  observeReplay(input: AdObservationRequest): Promise<AdObservation>;

  emitProgress(event: AdProgressEvent): void;
}

export type AdStepExecution = {
  action: SessionAction;
  source: {
    scriptPath: string;
    sourcePath: string;
    line: number;
    ordinal: number;
  };
  provenance: 'authored-ad-step';
  recordAs?: string;
  targetGuard?: ReplayTargetGuardDenotation;
  waitLandmark?: TargetAnnotationV1;
};

The daemon runtime adapter owns translation to DaemonRequest, parent flag merging, internal.replayPlanStep, target/landmark guards, session-scope inheritance through internal.resolvedSessionScope, request-level provider-scope reuse, and response/error projection. observeReplay updates the stored operational observation and returns opaque, one-shot capture-lineage evidence. Daemon response composition projects the exact inline response or successfully written overflow artifact, consumes and validates that evidence synchronously, and only then activates the matching partial frame. Every finalization attempt consumes the token, including empty, cancelled, invalid, and stale outcomes. The engine cannot publish refs or access session/store authority; no asynchronous work may occur between activation and returning the projected response. The outer replay keeps its stable session lock plus the device lock when known through this finalization. Same-session nested actions reuse the admitted scope; their meaningful changes invalidate lineage through observation, ref-frame, runtime-revision, or session-lifetime checks.

The engine receives no session interface. A daemon-owned coordinator wraps execute inside the already locked scope:

  1. Before execution, it validates the session-specific corrective-action watermark and configures repair recording.
  2. The runtime adapter arms recording when execution creates the session.
  3. After a divergence outcome, it stores the returned resume watermark.
  4. After success, it marks the repair transaction complete.
  5. It maps the typed outcome into the daemon response and lifecycle decision.

Arm, watermark, complete, abort, and tombstone operations remain private session implementation details; they are not engine-callable methods. The production and deterministic in-memory runtime adapters make AdReplayRuntime an earned seam rather than speculative indirection.

Session script publication

Publication is a session capability, not a native replay runtime entry point:

type ScriptPublicationTarget = Readonly<{
  path: string;
  source: 'generated' | 'explicit' | 'healed-sibling';
  force: boolean;
}>;

type RepairPlatformCloseReceipt = Readonly<{
  operationKey: string;
}>;

interface SessionScriptPublication {
  publishActive(input: ActivePublicationRequest): Promise<ScriptPublicationOutcome>;
  finalizeExplicitClose(input: {
    retarget?: { path: string; force?: true };
    platformClose: {
      operationKey: string;
      perform(): Promise<void>;
    };
  }): Promise<ScriptPublicationOutcome>;
  finalizeLifecycleTeardown(
    reason: 'idle-reap' | 'daemon-shutdown',
  ): Promise<ScriptPublicationOutcome>;
}

This is a daemon-private, request-bound capability. Passing platformCloseSucceeded: boolean would lose the operation identity and make sequencing the caller's responsibility. Instead, the capability receives one narrow close operation and controls when it runs and when its success receipt commits. It owns:

  • ADR 0016 eligibility, ARMED/ABORTED/PUBLISHED transitions, and repair-state disjointness;
  • mutually exclusive tagged ordinary-recording and repair states rather than co-resident mutable flags that callers must interpret;
  • publication-side repair commit/tombstone transitions from ADR 0012;
  • action-slice selection, durable target/per-target force authorization, close receipts, and retry semantics;
  • portability, unresolved-ref, target-evidence, and destination-guard validation;
  • atomic create-exclusive or explicitly forced replacement;
  • the store-derived active-session view used by response projection; and
  • coordination across active publication, ordinary close, repair close, idle reap, and daemon shutdown.

SessionScriptPublication and a daemon-private ReplaySessionTransaction are two capability projections over one private tagged aggregate, not two copies of session state. The replay projection owns arm, corrective-action watermark, divergence, completion, and abort transitions; the publication projection owns all artifact eligibility and commit transitions. Only the daemon-owned locked replay coordinator sees the replay projection, and neither projection is passed to an engine.

Do not implement the following sketch verbatim. It illustrates the one-aggregate/two-projection invariant and the minimum retry state. Issue #1478 owns the exact worker contract and supersedes this TypeScript wherever it is more specific.

The current cluster of co-resident flags collapses into one state:

type OrdinaryPublicationStatus =
  | { kind: 'armed' }
  | { kind: 'published'; path: string }
  | { kind: 'aborted'; reason: 'second-open' };

type RepairPublicationStatus =
  | { kind: 'armed' }
  | { kind: 'complete' }
  | {
      kind: 'close-succeeded';
      completion: 'armed' | 'complete';
      receipt: RepairPlatformCloseReceipt;
    }
  | { kind: 'committed'; path: string; receipt?: RepairPlatformCloseReceipt }
  | {
      kind: 'aborted';
      reason: 'explicit-close-incomplete' | 'lifecycle-incomplete' | 'teardown-commit-failed';
      receipt?: RepairPlatformCloseReceipt;
    };

type SessionScriptState =
  | { kind: 'inactive' }
  | {
      kind: 'ordinary';
      target: ScriptPublicationTarget;
      status: OrdinaryPublicationStatus;
    }
  | {
      kind: 'repair';
      boundary: number;
      sourcePath: string;
      target: ScriptPublicationTarget;
      watermark?: ResumeWatermark;
      status: RepairPublicationStatus;
    };

ADR 0016 ordinary publication and ADR 0012 repair ownership are then type-level alternatives, rather than nullable fields whose legal combinations every caller must remember. The repair close transaction has one required ordering:

  1. Derive a candidate target from the request without mutating state. Force remains authorization for one target: changing the path without a live force clears the old authorization.
  2. If no receipt matches the effective platform-close operation key, perform that close. A failure leaves the aggregate byte-for-byte unchanged.
  3. After close succeeds, persist the target and close-succeeded receipt before attempting atomic publication.
  4. If publication fails, retain that state. A retry with the same close operation skips platform dispatch and can safely retarget publication; a different operation key dispatches again.
  5. Publication transitions to committed, retaining its receipt until the lifecycle coordinator deletes the session. An incomplete repair transitions to explicit aborted state and never publishes. Teardown persists the existing bounded tombstone before deleting failed or incomplete repair state.

The tagged aggregate and both capability projections must land together; do not temporarily keep a second repair/publication state beside it. Centralizing the callers behind the locked replay coordinator can follow as a separate migration step because it changes orchestration, not state ownership.

It uses an internal pure .ad codec for canonical serialization. The codec is shared syntax, not the replay engine and not an injected port; the local filesystem remains directly testable through temporary directories. Publication never asks replay to interpret SessionState, and replay never owns session deletion or lifecycle ordering.

This is the highest-risk extraction. Today session-script-writer.ts owns artifact preparation and atomic commit, while active eligibility/transitions, close sequencing, and teardown tombstones live in separate callers. There are five production writeSessionLog call sites: active publication, ordinary close, repair close, lifecycle teardown, and a surface-dormant call after record-only record stop. Supported CLI, Node, and MCP surfaces cannot attach saveScript to record, but a raw JSON-RPC request can: recordSessionAction mirrors unvalidated request flags, recordActionEntry sets recordSession when saveScript is truthy, and releaseRecordOnlySession(..., { writeLog: true }) then reaches the writer. This is accidental daemon-seam behavior—there is no daemon-side per-command flag allowlist—not a fifth supported publication trigger. Closing it is ADR 0003-compatible hardening, not a public contract removal. First pin that every record action rejects or ignores saveScript without arming publication; then delete the writer call. Preserve the four supported semantics behind one capability before moving formatting code.

Maestro

Keep the existing deep runtime port:

export type MaestroRuntimePort = {
  execute(request: MaestroRuntimeRequest): Promise<MaestroRuntimeResult>;
  observe(request: MaestroObservationRequest): Promise<MaestroObservation>;
  readMetrics?(): MaestroRuntimeMetrics;
};

Add one façade:

inspectMaestroFlow(source): MaestroFlowManifest;
prepareMaestroFlow(source, options): Promise<PreparedMaestroFlow>;
executeMaestroFlow(
  prepared,
  runtime: MaestroRuntimePort,
  options,
): Promise<MaestroRunOutcome>;

PreparedMaestroFlow is opaque except for stable metadata such as digest and step total. Typed failure identity should be returned in MaestroRunOutcome, not reconstructed from a best-effort observer side channel.

Move these daemon-specific files out of the Maestro module:

  • daemon-runtime-port.ts;
  • daemon-runtime-port-support.ts;
  • daemon-runtime-port-observation.ts;
  • daemon-runtime-port-snapshot-source.ts;
  • daemon-runtime-public-operation.ts.

Their destination is src/daemon/adapters/maestro/. Neutral snapshot presentation and tree helpers with both daemon and Maestro consumers move to src/snapshot/.

Of these five files, daemon-runtime-port-support.ts and daemon-runtime-public-operation.ts directly import daemon/types.ts; the other three are daemon-specific by role rather than by that direct dependency.

No code outside Maestro imports its IR, plan steps, parser, selector ranking, expression state, or compatibility internals.

Replay-test

The test runner is the highest-confidence extraction. It already has one production caller and a real callback seam:

  • runReplay;
  • cleanupSession;
  • finalizeAttempt.

Its public API should become:

export function runReplayTestSuite(
  request: ReplayTestSuiteRequest,
  host: ReplayTestHost,
): Promise<ReplayTestSuiteResult>;

export interface ReplayTestHost {
  inspect(sourcePath: string): Promise<ReplayTestManifest>;
  execute(input: ReplayTestAttempt): Promise<ReplayTestAttemptResult>;
  finalize(input: ReplayTestFinalization): Promise<readonly ArtifactRef[]>;
  cleanup(input: ReplayTestCleanup): Promise<void>;
  progress: ReplayTestProgressPort;
}

The scheduler owns discovery, filtering, retries, fail-fast, sharding, timeouts, result aggregation, and reporters. It does not import either engine, daemon types, platform types, global request progress, or SessionStore.

The daemon adapter selects the .ad or Maestro implementation and owns:

  • session naming;
  • nested in-process invocation;
  • video lifecycle;
  • artifact registration;
  • cancellation binding;
  • mapping neutral failures to DaemonError.

The common interface stops at inspect/execute for test orchestration. Direct .ad repair/resume and Maestro control-flow APIs remain engine-specific.

Session runtime

Do not replace the roughly 266-line SessionState declaration with one giant class containing 100 methods. Build an internal composition object around demonstrated state machines:

interface LockedSessionContext {
  readonly id: string;
  readonly device: DeviceInfo;
  view(): SessionView;

  readonly refs: RefFramePort;
  readonly replay: ReplaySessionTransaction;
  readonly scripts: SessionScriptPublication;
  readonly recording: RecordingSessionPort;
  readonly resources: SessionResourceLedger;
  readonly lease: SessionLeasePort;
}

Only request execution can create a LockedSessionContext. It is an internal capability bag, not a stable engine interface or package export. The replay adapter receives only AdReplayRuntime; the daemon-owned replay coordinator closes over the locked context. The store's live mutable access becomes capability-only, while ordinary readers receive immutable SessionView snapshots.

The aggregate does not pretend all session data has one transaction model. It composes three disciplines:

  1. Immediate pessimistic transitions for ref frames and observation lineage: commit synchronously before an asynchronous side effect and never roll back on its failure.
  2. Staged protocols for repair and publication: arm, establish a watermark, complete, then settle any platform close, commit or abort, and preserve close receipts or failure tombstones across retries.
  3. Append-only streams for recorded actions and journal events: append facts without using them to coordinate mutable state.

Migration can then proceed field-cluster by field-cluster:

  1. ref frame (already close to this shape);
  2. replay repair/publication;
  3. recording;
  4. runtime resources;
  5. lease and advisory claim;
  6. observation/snapshot state.

R7 is the migration vehicle, not a temporary allowlist. At the measured commit it covers 30 writer-owned fields through 42 owner-file claims, plus 11 store-established fields. For each cluster: pin its invariant, route its writes through transition methods, collapse its nullable fields into one tagged state, expose the corresponding immutable view(), and replace its R7 rows with the new capability owner. The shrinking field/owner table reports progress while continuing to reject undeclared writers.

A resource ledger records cleanup when a runner, recording, app log, audio probe, perf capture, IME, helper, or materialized path is acquired. Close, expiry, idle reap, and shutdown call one lifecycle coordinator that preserves cleanup ordering and failure isolation.

Platform boundary

Keep Interactor and the provider-first seam. Replace global platform registration with an injected immutable registry, and eventually bind one request-scoped device runtime:

export interface DeviceRuntimeGateway {
  discover(request: DeviceInventoryRequest): Promise<readonly DeviceInfo[]>;
  bind(context: DeviceBindingContext): Promise<BoundDeviceRuntime>;
}

export interface BoundDeviceRuntime {
  readonly interactor: Interactor;
  readonly appLog?: AppLogPort;
  readonly recording?: RecordingPort;
  readonly performance?: PerformancePort;
  readonly lifecycle?: DeviceLifecyclePort;
  release(): Promise<void>;
}

This can reduce the eight resolver-shaped fields of the 20-field RequestRouterDeps to one binding dependency; unrelated router dependencies remain. It does not invent another generic command dispatcher. Concrete runner, ADB, helper, browser, log, recording, and perf types stay inside platform/provider adapters. Session state stores neutral resource references, not Apple capture objects or Android process unions.

Only the composition root may import concrete platform packages.

Source layout before workspaces

src/
  ad-replay/
    index.ts
    internal/
  maestro/
    index.ts
    runtime-port.ts
    internal/
  replay/
    ad-codec/
      index.ts
    test/
      index.ts
      internal/
  daemon/
    adapters/
      ad-replay/
      maestro/
      replay-test/
      platform/
    session/
      refs/
      replay-coordinator/
      scripts/
      recording/
      resources/
      lifecycle/

Barrels exist only at these module boundaries. Internal code uses direct sibling imports.

This layout defines locality operationally: each module directory owns one façade, its internal/ implementation, seam contracts/adapters, and tests that mirror the source topology. A contributor starts at the façade and should remain inside that directory except for named ports and leaf contracts. Zero imports into another module's internal/ tree enforce that reading model. R9 remains the anti-locality metric for type hubs that file moves do not fix.

Conditional workspace layout

If package triggers are met after imports become one-way, use this candidate order:

packages/
  ad-replay/
  maestro/
  replay-test/

Each package:

  • is "private": true initially;
  • uses workspace:* dependencies;
  • exports only "." unless a second real consumer earns a subpath;
  • has TypeScript project references;
  • cannot import root src, daemon, platform implementations, or another engine;
  • is bundled into the existing single published agent-device artifact;
  • preserves dynamic platform loading.

Do not create foundation, common, or program-api by default. Extract a low-level contracts package only from types with multiple real package consumers. The first candidate should be a small snapshot/selector contract, not a dump of root utilities.

Session runtime and platform packages should remain logical modules until their APIs and build ownership are equally clear.

Enforcement

Use the declarative zone-policy work from PR #1449 as the repository-wide policy source. Its eslint-plugin-boundaries spike correctly identified tradeoffs: no ratchet, an inline type import false positive, alpha plugin loading, and a large transitive dependency cost. Do not add that plugin now.

Enforce the new shape with:

  1. the existing layering and dependency-graph gates;
  2. explicit new module zones and zero-count import rules;
  3. zero imports into another module's internal/ tree;
  4. R7 field ownership as the session migration ratchet;
  5. package exports when workspaces land;
  6. TypeScript project references;
  7. no wildcard exports or internal barrels;
  8. contract suites run against the daemon adapter and its deterministic in-memory twin;
  9. provider integration scenarios that still replace providers below platform translation.

A proposed port does not enter the target policy until both implementations pass the same contract suite. Local-substitutable dependencies such as filesystem writes continue to use temporary directories directly rather than gaining mock-only ports. No event-bus dependency is permitted: module control flow stays explicit and call-based.

Target policy:

Module Forbidden imports
ad-replay daemon, platforms, providers, compat, Maestro
Maestro daemon, platforms, providers, ad-replay
replay-test daemon, platforms, providers, both engine internals
session capabilities handlers, transport, concrete platforms
daemon handlers engine internals, concrete platform internals
daemon adapters may bridge only the explicitly named sides

Tradeoffs and mitigations

Tradeoff Mitigation
More adapter code Quarantine request-shape complexity in adapters, keep each below 300 LOC, and require a contract-tested in-memory twin.
Internal public APIs need discipline Review them as APIs but change them atomically with the repository: one façade file, no deprecation theater, no deep imports.
Softer enforcement under src/ Use the existing R4/R5 gates, zero-count module rules, and an internal/ import ban; promote to workspaces only when a measured trigger appears.
Session encapsulation is difficult Migrate tagged field clusters independently, preserve their distinct consistency disciplines, and use R7 plus focused invariant suites as the ratchet.
Facets may reveal missing abstractions or add LOC Treat that as discovery. Ratchet the 88 daemon platform-branch sites; LOC alone is not evidence that the seam improved.
Workspaces add build and packaging complexity Keep Phase 6 conditional and preserve the single published artifact and lazy platform loading.

Migration sequence

Phase 0: pin and ratchet

  • Land or rebase the declarative policy table from PR #1449.
  • Record current dependency counts and add zero-count targets for new modules.
  • Use the frozen replay-compat corpus under test/replay-compat/ to pin released .ad parse behavior and provenance.
  • Use test/integration/nightly/concurrency-torture.test.ts to pin session/lease/lock serialization, and provider scenarios to pin provider-first behavior through real platform translation.
  • Keep focused contract tests for nested same-session execution, provider-scope reuse, ref expiry, replay divergence/resume, repair and active publication, and Maestro runtime-port behavior.
  • Define neutral module input, outcome, and ADR 0010 failure contracts before moving implementations. SessionAction, ReplaySuiteResult, and ReplaySuiteTestResult already live in contracts/ at the measured commit; do not move daemon-private DaemonRequest down merely to make an import disappear.
  • Eliminate the four remaining production importers of daemon/types.ts from outside daemon: client and remote code use their existing public contracts, while the two Maestro files move into the daemon adapter. Keep DaemonInvokeFn adapter-local.
  • Record both the 102-file R9 size and its zone membership. Engine extraction must not increase it or pull an engine file into it; session and platform phases lower TYPE_CYCLE_BASELINE in the same change whenever the component shrinks.
  • Record R7's current 30 writer-owned fields and 42 owner-file claims. Each session capability move must shrink or consolidate that table and move ordinary readers to immutable view() projections.
  • Do not add duplicate production paths or fallback engines.

Phase 1: extract replay-test

  • Move the eight session-test* implementation files beside existing replay-test reporters.
  • Replace DaemonRequest/DaemonResponse with neutral request/result types.
  • Replace global cancellation/progress reads with AbortSignal and an explicit progress port.
  • Leave a thin daemon adapter for nested execution, video, cleanup, artifacts, and response mapping.
  • Split the moved tests to mirror the new source topology.
  • Keep every replay-test file outside the largest type SCC.

This relocates about 1,967 production LOC; the net daemon-server reduction is that amount minus the thin attempt adapter that remains.

Phase 2: correct Maestro ownership

  • Add a Maestro façade and route all non-test consumers through it.
  • Move the five daemon runtime files to src/daemon/adapters/maestro.
  • Move genuinely neutral snapshot helpers to src/snapshot.
  • Move source-kind selection above native replay so .ad execution has no Maestro imports.
  • Return typed failure details from the engine outcome.
  • Gate Maestro-to-daemon imports at zero.
  • Remove the two compat/maestro production imports of daemon/types.ts by moving those adapter roles, not by promoting DaemonRequest into contracts.

This is mostly ownership correction around an already-proven port.

Phase 3: encapsulate replay/session transactions

  • Consolidate the spread of repair fields into one private replay state capsule.
  • Add one daemon-owned locked replay coordinator that wraps engine execution and privately applies corrective-resume admission, arm, watermark, complete, abort, and tombstone transitions. Do not expose those transitions to the engine.
  • Put active, ordinary-close, repair-close, and lifecycle-teardown publication behind SessionScriptPublication, preserving ADR 0016 disjointness and store-derived session activity. Collapse output target, per-target force authorization, repair completion, committed/aborted status, and the operation-keyed platform-close receipt into the tagged aggregate. Pin that a failed platform close leaves state unchanged; a failed publication retains the receipt; the same close retry skips dispatch; and a different close identity dispatches again.
  • At the daemon request seam, pin that every record action rejects or ignores unsupported saveScript without arming publication. Only then delete the surface-dormant but raw-wire reachable record stop writer invocation.
  • Move each field cluster behind transition methods, collapse it to tagged state, update R7 in the same change, and stop new code from receiving mutable SessionState.
  • Reserve the live store record for capability implementations; move ordinary readers to immutable SessionView projections.
  • Introduce the resource ledger and one lifecycle coordinator before changing teardown ownership.
  • Replace concrete platform perf objects in SessionState with opaque resource handles owned by the ledger/platform adapter. The Apple perf-xctrace.ts type edge is the load-bearing R9 edge; do not recreate it under a new name. Removing the Android perf edge is ownership hygiene but does not shrink the component by itself.

Phase 4: extract native replay

  • Add the prepare/execute engine façade under src/ad-replay.
  • Move parsing, includes, planning, variables, digest construction, verification, resume rules, divergence construction, and typed outcomes behind it.
  • Implement AdReplayRuntime in src/daemon/adapters/ad-replay.
  • Keep canonical .ad syntax in a pure internal codec shared with session publication. Publication keeps portability/destination validation, atomic commit, and lifecycle authority.
  • Preserve inherited session scope, request-level provider scope, the existing lock, and the side-effect/ref-frame seam. observeReplay updates operational observation and returns opaque lineage evidence; daemon response composition alone activates the exact projected partial frame.
  • Delete superseded helpers and handler-mock tests as their interface replacements land.

Phase 5: narrow platform binding

  • Inject a platform registry rather than mutate a global registry.
  • Wrap current local/provider selection in a request-bound DeviceRuntimeGateway.
  • Move snapshot acquisition, concrete logs, recording, perf, and lifecycle mechanics behind focused facets.
  • Keep selector/ref policy, settle/verify, response construction, and guarantee classification in daemon/core.
  • Ratchet concrete daemon-to-platform imports toward composition/adapters only.
  • Recompute the platform-conditional branch inventory and ratchet it down as plugin facets absorb mechanics; a gateway that leaves the branches in daemon has not improved the seam.
  • Recompute the R9 component after removing concrete platform state from session contracts and lower TYPE_CYCLE_BASELINE immediately. The measured Apple-perf edge cut makes 91 the first evidence-backed upper bound, not an aspirational guess; the Android-only cut leaves 102 unchanged.

Optional Phase 6: create private workspace packages only when earned

Logical modules are the target architecture; workspaces are not a scheduled milestone. Promote a module only when it has zero root back-imports, a stable façade exercised by daemon and in-memory adapters, and at least one measured reason that zone gates plus module exports are insufficient:

  • a second real build or consumer;
  • independent ownership, test caching, or declaration checking that pays for package plumbing; or
  • repeated boundary regressions that physical package resolution would prevent more reliably.

When a trigger exists, add packages/* to pnpm-workspace.yaml, move ad-replay, Maestro, then replay-test independently, configure tsdown to retain the single published artifact and lazy platform imports, and add package build/declaration checks. If no trigger appears, stop with the logical modules.

Phase 7: consider ADR 0018

The event journal can simplify diagnostics/session-event/replay-trace finalization, but it is not a prerequisite for the module split. Progress remains separate.

Success criteria

Measure information hiding, not just moved lines:

Metric Current Target
daemon-server production LOC 46,504 below 42,000 after engine extraction and thin adapters
compat → daemon dependencies 5 0
replay-test imports from daemon current direct coupling 0
engine-to-engine imports 0 0
R7 writer-owned fields / owner-file claims 30 / 42 shrinking to one owner row per capability-owned tagged cluster
direct mutable SessionState writers outside session capabilities R7-policed 0
live SessionStore record access 62 production store importers, readers and writers mixed capability-only; ordinary readers use immutable SessionView
daemon → platform value dependencies 80 composition/adapters only
daemon platform-conditional branch sites 88 in the dependency audit decreasing per extracted facet; composition-only residuals justified
concrete platform state types in SessionState several 0
external production importers of daemon/types.ts 4 0 by Phase 2
direct daemon/types.ts → platform implementation imports 2 0
engine files in largest type SCC 0 remain 0
largest value-plus-type SCC (R9) 102 files no growth in engine phases; at most 91 after platform-resource extraction
daemon-server files in largest SCC 30 at most 21 after platform-resource extraction
TYPE_CYCLE_BASELINE 102 equal to each newly achieved lower value
RequestRouterDeps provider resolver fields 8 of 20 total fields 1 request-bound binding; unrelated fields unchanged
script publication assembly paths 4 supported paths plus 1 surface-dormant/raw-wire writer path 1 session capability; unsupported flag refused and writer call deleted
replay/test route adapter size mixed into handlers each below 300 LOC
public runtime exports per engine many deep imports one façade
imports into another module's internal/ tree not yet zoned 0
runtime cycles/back-edges 0 / 0 0 / 0

The identified replay/test/Maestro integration slices contain roughly 7,300 LOC of domain behavior. Some of that must remain as daemon adapters, and correcting Maestro ownership first moves about 1,550 adapter LOC from compat toward the daemon side. A realistic net daemon-server reduction is roughly 4,500–6,000 LOC, depending on how much response/artifact/session adaptation remains. Total repository LOC will initially remain close to flat. The immediate benefit is that a contributor can answer one question by reading one module and can only cross a boundary through a reviewed type. For platform extraction, the branch-site count—not LOC—is the honest measure of whether mechanics actually moved behind facets.

Design probes and production evidence

Two throwaway prototypes live under scripts/prototypes/daemon-boundaries/.

The program-boundary prototype exercises .ad and Maestro through one test-level inspect/execute interface while recording separate native and Maestro runtime-port calls. It demonstrates that the test scheduler can be engine-neutral without a shared VM.

The session-boundary prototype exercises:

  • ref activation and expiry before both successful and failed mutations;
  • parameterized recording;
  • one private tagged aggregate exposed through separate replay and publication capability projections;
  • repair arm, corrective-resume watermark, completion, close receipt, retry, and commit on the same aggregate instance;
  • platform-close failure without state mutation;
  • durable target/force and close-receipt state across a failed publication;
  • same-close retry without redispatch, changed-close redispatch, and explicit committed/aborted terminal states;
  • client resume-digest validation outside session state;
  • no-clobber active publication and repair/publication disjointness.

It demonstrates that these invariants can be expressed through two narrow capability projections over one aggregate without exposing a mutable session record or maintaining parallel repair and publication state.

These are interface-thinking aids, not feasibility evidence. The stronger production evidence is the existing replay-test seam: session-test-types.ts already describes runReplay, cleanupSession, and finalizeAttempt, and session-replay.ts is its single production adapter. The migration should deepen and relocate that seam instead of creating a parallel scheduler.

Run them with:

pnpm prototype:program-boundary
pnpm prototype:session-boundary

Keep this proposal, the dependency-findings update, the scripts-local README, and both executable probes in one change. The root scripts make these deliberate executable entry points for humans and Fallow; they remain throwaway logic models, not production scaffolding or regression suites, and should be removed with the probes after the migration questions are settled.

Implementation handoff

Issue #1478 is the versioned execution plan. It records the post-review interface decisions, compatibility constraints, dependency-ordered worker briefs, STOP conditions, success metrics, and final documentation cleanup. The sketches in this proposal illustrate those constraints; they are not a standalone worker specification. Where the issue is more specific, implement the issue rather than extrapolating from a sketch here.

This proposal remains the source for rationale, ADR constraints, measurements, tradeoffs, and probe evidence. The optimization target is the minimum context and authority required for a safe contribution, not minimum LOC or the maximum number of packages.