diff --git a/docs/adr/0019-end-state-hop-trace.md b/docs/adr/0019-end-state-hop-trace.md index bac286b52f..98d604b293 100644 --- a/docs/adr/0019-end-state-hop-trace.md +++ b/docs/adr/0019-end-state-hop-trace.md @@ -3,8 +3,17 @@ Backs the "Entry-to-platform hop count" paragraph in [0019-request-bound-platform-runtime.md](./0019-request-bound-platform-runtime.md#end-state-proposed-2026-09-02-maintainer-decision-pending). That paragraph cited 38 hops (`press`/Android) and 29 hops (`snapshot`/iOS) with no ordered -route, no counting definition, and no linked artifact. This file supplies all three, re-traces -both routes file-by-file at HEAD, and replaces the unauditable numbers. +route, no counting definition, and no linked artifact. This file supplies all three. + +The 23/24 tables originally recorded here at `132ffe1da` (corrected for PR #2302 in that PR) +are superseded: the routes moved ~58 commits, `snapshot`/iOS is now a dual-arm route, and the +#2278 audit re-traced both routes file-by-file at HEAD with a role vocabulary. This revision +replaces the tables and keeps the counting definition stable so the numbers remain comparable. +A follow-up revision re-traced the request spine: the auth token comparison, the request +cancellation gate, the idle-reap timer reset, and the Android freshness guard/clear are +side-calls (guards and liveness bookkeeping), not the call that continues toward the platform +boundary, so they are not hops. The tables and every derived number below are recomputed under +that reading. ## Counting definition @@ -12,87 +21,251 @@ both routes file-by-file at HEAD, and replaces the unauditable numbers. re-entered (a lazy `import()` of an already-counted file is not a new hop). Type-only imports and files that only supply types are not hops. - **Range**: from the daemon's HTTP entry file (`src/daemon/server/http-server.ts`) through the - file that issues the concrete platform call — `adb shell input tap` for `press`/Android, - `fetch()` to the XCTest runner for `snapshot`/iOS — inclusive of both endpoints. -- **Per-file classification**: - - **pass-through** — delegates to exactly one next call with no branching or transformation - (a lazy-load indirection, a one-line re-export/delegate). - - **thin** — a routing table, a switch on command name, or a wrapper around one admission - check; no domain logic. - - **substantive** — resolves a plan, binds a runtime, constructs an interactor, or otherwise - does work a target hop count would still need somewhere. - - **terminal** — the file that issues the platform call itself. -- **Method**: read each file, find the exported entry function reached by the caller, follow its - first call that continues toward the platform call (not every branch — e.g. `handleSnapshotCommands` - routes `alert`/`settings`/`diff`/`wait` too; only the `snapshot` arm is traced), record the file - and the one line/function that hands off to the next hop. -- **Commit measured at**: originally `132ffe1da296717c268e836fc02558d00e61cfbd` (this branch's - base, `origin/main` fast-forwarded). PR #2302 (refactor(daemon): inline the touch sub-switch - into the interaction dispatcher) merged `interaction-touch.ts` into `interaction.ts`, collapsing - hop 6 into hop 5 of the `press`/Android table below; that table (and the counts and hop - references that depend on it) has been updated in the same PR so the trace does not go stale - on merge. + file that issues the concrete platform call, inclusive of both endpoints. For `press`/Android + that is the serial-scoped `adb shell input tap` invocation; for `snapshot`/iOS it is the + `socket.write` to the in-simulator AX bridge (primary arm) or the `fetch()` to the XCTest + runner (fallback arm). +- **Main chain only**: follow the first call that continues toward the platform call (not every + branch — e.g. `handleSnapshotCommands` routes `alert`/`settings`/`diff`/`wait` too; only the + plain-`snapshot` arm is traced). Side-calls that build data records inside a hop (e.g. the + `*RuntimeOperationFacts` builders inside the owner's per-request `inspectFacts`) are not hops; + they are measured as admission fan-out below. Guards and liveness side-effects on the request + spine are not hops either: the auth token comparison (`timingSafeStringEqual`), the request + cancellation gate (`throwIfRequestCanceled`), the idle-reap timer reset (`cancel()`), and the + Android freshness guard/clear run on every request as sibling statements of the continuation, + not as the call that advances toward the platform. Files executing only on the response path + (after the platform call, on the way back) are not hops; they are counted separately — the + freshness clear is one such response-path call. +- **Steady state**: the `snapshot` fallback arm is traced with the runner session already up. + The cold-start chain (lease, xctestrun build via xcodebuild, process launch) is a fork, not + the traced path. +- **Per-file role** (exactly one): + - **policy** — makes an admission/limit/retry/routing decision that can refuse or redirect the request. + - **orchestration** — coordinates multiple calls, binds/runs a runtime, or assembles the object graph the next hops need. + - **translation** — maps values/shapes (argv construction, wire encoding, target resolution) without issuing the platform call. + - **adapter** — wraps an external system boundary (subprocess, socket, fetch). The terminal hop is the adapter that issues the platform call itself. + - **pass-through** — delegates to exactly one next call on the traced path with no branching or transformation (lazy-load indirection, one-line re-export/delegate, scope wrapper). +- **Deletion test** (per pass-through/translation hop): could this hop's hand-off be inlined + into its caller and/or callee without losing admission, budget, isolation, a declared + boundary named in an ADR, or a testability seam? The test judges the hop, not the file: a + REMOVABLE hop is a collapse candidate, not a file to delete. +- **Method**: read each file, find the exported entry function reached by the caller, follow + its first call that continues toward the platform call, record the file and the one + function that hands off to the next hop. +- **Commit measured at**: `27a97ee619` (re-traced for #2278; supersedes the `132ffe1da` tables). ## `press` / Android: HTTP entry → `adb input tap` -23 files, 6 pass-through, 9 thin, 7 substantive, 1 terminal. - -| # | File | Hand-off | Class | -|---|------|----------|-------| -| 1 | `src/daemon/server/http-server.ts` | parses the HTTP request, calls `handleRequest` | thin | -| 2 | `src/daemon/request-router.ts` | `createRequestHandler` → `runRequestHandlerChain` | pass-through | -| 3 | `src/daemon/request-handler-chain.ts` | routes `command: 'press'` to `runInteractionHandler`, lazy-loads `interaction/index.ts` | thin | -| 4 | `src/daemon/interaction/index.ts` | re-exports the lazy-loaded internal module's `handleInteractionCommands` | pass-through | -| 5 | `src/daemon/interaction/internal/interaction.ts` | `handleInteractionCommands` switches `press`/`click`/`longpress`/`hover` to `dispatchTargetedTouchViaRuntime` (`fill` to `dispatchFillViaRuntime`) | thin | -| 6 | `src/daemon/interaction/internal/interaction-touch-press.ts` | admits the touch, tries the direct-iOS fast path (no-op on Android), calls `dispatchRuntimeInteraction` with a `run` callback that calls `runtime.interactions.press` | substantive | -| 7 | `src/daemon/interaction/internal/interaction-touch-prepare.ts` | `prepareTouchDispatch` → `resolveBoundTouchRuntime` | thin | -| 8 | `src/daemon/touch-runtime.ts` | resolves the touch plan, calls `bind(device, tapPointUse)`, wraps the result as `BoundTouchExecutor.tapPoint` | substantive | -| 9 | `src/daemon/runtime-admission.ts` | `admitRuntimeOperations` → `requireDeviceBinding(bindDevice)` | thin | -| 10 | `src/daemon/request-runtime-binding.ts` | `bindDevice` → per-device-cached `gateway.bind(...)` | substantive | -| 11 | `src/platform-runtime-gateway.ts` | composed gateway's `loadLocal`: loads the host, calls `module.loadRuntime(host)` | substantive | -| 12 | `src/platform-runtime.ts` | declared boundary (ADR §1/§2): wires `androidRuntimeModule` into `platformRuntimeModules` and supplies `loadHost` | thin | -| 13 | `src/platform-runtime-operation-host.ts` | `createPlatformRuntimeHost` builds `host`, incl. `localInteractors: createLocalApplicationInteractorHost()` | thin | -| 14 | `src/platform-runtime-local-application-interactors.ts` | `resolve()` lazy-imports `core/interactors.ts`, calls `getLocalInteractor` | pass-through | -| 15 | `src/core/interactors.ts` | `getLocalInteractor` → `getPlugin(device.platform).createInteractor` | pass-through | -| 16 | `src/core/interactors/register-builtins.ts` | plugin registry; the `android` entry lazy-imports `./android.ts` | thin | -| 17 | `src/core/interactors/android.ts` | `createAndroidInteractor` builds the `Interactor`, incl. `tap: (x, y) => pressAndroid(device, x, y)` | substantive | -| 18 | `packages/platform-android/src/index.ts` | declared boundary (ADR §2 `loadRuntime` pairing): lazy-imports `./runtime.ts`, calls `createAndroidPlatformRuntime` | pass-through | -| 19 | `packages/platform-android/src/runtime.ts` | `createAndroidPlatformRuntime`'s `bind()` builds `operations` via `androidInteractionOperations` | substantive | -| 20 | `packages/contracts/src/local-interactor-operation-set.ts` | `bindLocalInteractorOperationSet` → `bindLocalTouchInteractor` | pass-through | -| 21 | `packages/contracts/src/touch-runtime.ts` | `bindTouch`'s `tapPoint` op resolves the interactor (re-enters hop 14's `resolve`), then `executeGenericPress` calls `interactor.tap(x, y)` | substantive | -| 22 | `packages/platform-android/src/input-actions.ts` | `pressAndroid(device, x, y)` builds the adb argv, calls `runAndroidAdb` | thin | -| 23 | `packages/platform-android/src/adb.ts` | `runAndroidAdb` issues `adb shell input tap ` | **terminal** | - -## `snapshot` / iOS Simulator (XCTest): HTTP entry → runner fetch - -24 files, 5 pass-through, 7 thin, 11 substantive, 1 terminal. - -| # | File | Hand-off | Class | -|---|------|----------|-------| -| 1 | `src/daemon/server/http-server.ts` | parses the HTTP request, calls `handleRequest` | thin | -| 2 | `src/daemon/request-router.ts` | `createRequestHandler` → `runRequestHandlerChain` | pass-through | -| 3 | `src/daemon/request-handler-chain.ts` | routes `command: 'snapshot'` to `runSnapshotHandler`, lazy-loads `handlers/snapshot.ts` | thin | -| 4 | `src/daemon/handlers/snapshot.ts` | `handleSnapshotCommands` routes the plain-snapshot arm to `dispatchSnapshotViaRuntime` | thin | -| 5 | `src/daemon/snapshot-runtime.ts` | `dispatchSnapshotViaRuntime` → `dispatchSnapshotRuntimeCommand` | pass-through | -| 6 | `src/daemon/snapshot-command-runtime.ts` | resolves the bound capture, wires session/backend, calls `params.execute` | substantive | -| 7 | `src/daemon/snapshot-runtime-binding.ts` | `resolveBoundSnapshotCaptureRuntime` → `admitAndBindSnapshotCapture` → `bindSnapshotCaptureRuntime` → `bind(device, plan.use)` on the `active-app` arm | substantive | -| 8 | `src/daemon/session-runtime-admission.ts` | `admitRuntimePlan` / `requireRuntimeBinding` | thin | -| 9 | `src/daemon/request-runtime-binding.ts` | `bindDevice` → per-device-cached `gateway.bind(...)` | substantive | -| 10 | `src/platform-runtime-gateway.ts` | composed gateway's `loadLocal`: loads the host, calls `module.loadRuntime(host)` | substantive | -| 11 | `src/platform-runtime.ts` | declared boundary: wires `appleRuntimeModule`, supplies `loadHost` | thin | -| 12 | `src/platform-runtime-operation-host.ts` | `createPlatformRuntimeHost` builds `host`, incl. `localInteractors` | thin | -| 13 | `packages/platform-apple/src/index.ts` | declared boundary (`loadRuntime` pairing): lazy-imports `./runtime.ts`, calls `createApplePlatformRuntime`; also holds `applePlugin.createInteractor`, entered once and reused at hop 20 | pass-through | -| 14 | `packages/platform-apple/src/runtime.ts` | `createApplePlatformRuntime`'s `bind()` calls `bindAppleSnapshotRuntime` when `captureSnapshot` is admitted | substantive | -| 15 | `packages/platform-apple/src/runtime-snapshot.ts` | `bindAppleSnapshotRuntime` branches macOS-surface vs. app-snapshot, resolves the local interactor | substantive | -| 16 | `packages/contracts/src/snapshot-runtime.ts` | `bindLocalSnapshotInteractor`'s `captureSnapshot` op resolves the interactor (hop 17), calls `interactor.snapshot(options)` | substantive | -| 17 | `src/platform-runtime-local-application-interactors.ts` | `resolve()` lazy-imports `core/interactors.ts` | pass-through | -| 18 | `src/core/interactors.ts` | `getLocalInteractor` → `getPlugin('apple').createInteractor` (the package-owned `applePlugin`) | pass-through | -| 19 | `src/core/interactors/register-builtins.ts` | plugin registry; the `apple` entry is `applePlugin`, imported directly from the package | thin | -| 20 | `packages/platform-apple/src/interactor.ts` | `createAppleInteractor`'s `snapshot` calls `captureAppleSnapshot` → `captureAppleRunnerSnapshot` | substantive | -| 21 | `packages/platform-apple/src/runner/runner-client.ts` | `runAppleRunnerCommand` resolves the runner provider, applies the read-only retry policy, calls `provider.runCommand` | substantive | -| 22 | `packages/platform-apple/src/runner/runner-lifecycle.ts` | `executeRunnerCommand`: recycle-budget/session bookkeeping, calls `ensureRunnerSession` then `executeRunnerCommandWithSession` | substantive | -| 23 | `packages/platform-apple/src/runner/runner-session.ts` | `executeRunnerCommandWithSession` → `sendRunnerCommandOnce` | substantive | -| 24 | `packages/platform-apple/src/runner/runner-transport.ts` | issues `fetch(url, ...)` to the XCTest runner process | **terminal** | +41 files: 11 pass-through, 5 translation, 5 policy, 19 orchestration, 1 terminal (adapter). + +The request spine also runs side-calls that are not hops — they execute on every request but are +not the call that continues toward the platform boundary: `src/daemon/server/daemon-idle-reap.ts` +(`cancel()` resets the liveness timer as a sibling of `dispatchRequest`), +`packages/host-kit/src/internal/transport.ts` (`timingSafeStringEqual` compares the auth token +before the continuation), and `packages/host-kit/src/internal/request-cancel.ts` +(`throwIfRequestCanceled` gates the `task()` continuation inside `runLocked`/`runAdmitted`). + +| # | File | Hand-off | Role | Delete-test | +|---|------|----------|------|-------------| +| 1 | `src/daemon/server/http-server.ts` | parses `/rpc`, resolves the token; calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | +| 2 | `src/daemon/server/daemon-runtime.ts` | `handleRequest`: in-flight count, cancels idle reap, `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | +| 3 | `src/daemon/request-router.ts` | auth, flag guards, scope creation, locked execution, handler chain | orchestration | KEEP — request admission + dispatch core | +| 4 | `packages/host-kit/src/internal/diagnostics.ts` | `withDiagnosticsScope` wraps the request task | pass-through | KEEP — per-request diagnostic attribution | +| 5 | `src/request/device-inventory-context.ts` | `withDeviceInventoryContext` wraps the task | pass-through | KEEP — request-scoped device inventory context | +| 6 | `src/core/dispatch-resolve.ts` | `withResolveTargetDeviceCacheScope` wraps the task | orchestration | KEEP — per-request target-resolution cache | +| 7 | `src/daemon/request-execution-scope.ts` | `createRequestExecutionScope`; `runLocked`/`runAdmitted` | orchestration | KEEP — scoped execution + lock/claim admission | +| 8 | `src/provider-device-runtime.ts` | `withProviderDeviceRuntimeScope` (`AsyncLocalStorage.run`) | pass-through | KEEP — provider-runtime scope boundary | +| 9 | `src/daemon/request-handler-chain.ts` | `runRequestHandlerChain` routes to the interaction handler | orchestration | KEEP — command routing | +| 10 | `src/daemon/interaction/index.ts` | route facade delegates to `handleInteractionCommands` | orchestration | KEEP — shared interaction route facade | +| 11 | `src/daemon/interaction/internal/interaction.ts` | switches `press` to `dispatchTargetedTouchViaRuntime` | policy | KEEP — touch command routing | +| 12 | `src/daemon/interaction/internal/interaction-touch-press.ts` | admits the touch, dispatches the runtime interaction | orchestration | KEEP — press admission + orchestration | +| 13 | `src/daemon/interaction/internal/interaction-touch-prepare.ts` | `prepareTouchDispatch` → `resolveBoundTouchRuntime` | translation | KEEP — shared admission + bind seam | +| 14 | `src/daemon/touch-runtime.ts` | resolves the plan, `bind(device, tapPointUse)` | orchestration | KEEP — tap runtime binding | +| 15 | `src/daemon/runtime-admission.ts` | `admitRuntimeOperations`: `inspectFacts`, per-op admission | policy | KEEP — per-operation admission | +| 16 | `src/daemon/request-runtime-binding.ts` | `bindDevice`: per-device-cached `gateway.bind` | orchestration | KEEP — device runtime binding | +| 17 | `src/platform-runtime-gateway.ts` | `loadLocal` → `loadHost` → `loadRuntime` | orchestration | KEEP — gateway boundary | +| 18 | `src/platform-runtime.ts` | wires the android module, supplies `loadHost` | pass-through | KEEP — declared ADR §1/§2 registry boundary | +| 19 | `src/platform-runtime-operation-host.ts` | `createPlatformRuntimeHost` assembles the host | orchestration | KEEP — host composition | +| 20 | `packages/platform-android/src/index.ts` | `loadRuntime` pairs the plugin with the runtime module | pass-through | KEEP — declared ADR §2 `loadRuntime` boundary | +| 21 | `packages/platform-android/src/runtime.ts` | `bind`: `inspectFacts` (fan-out site), builds the tap operation | orchestration | KEEP — android runtime owner | +| 22 | `packages/contracts/src/local-interactor-operation-set.ts` | `bindLocalInteractorOperationSet` → `bindLocalTouchInteractor` | translation | KEEP — tap op mapped to the interactor | +| 23 | `src/daemon/device-claim-admission.ts` | `admit`: owner claim rule; ordinary owner | policy | KEEP — device claim admission | +| 24 | `src/daemon/interaction/internal/interaction-touch-runtime.ts` | `dispatchRuntimeInteraction` → `createInteractionRuntimeForRoute` | orchestration | KEEP — interaction runtime dispatch | +| 25 | `src/daemon/interaction/internal/interaction-runtime.ts` | `createInteractionAgentDevice` with the executor backend | orchestration | KEEP — agent device construction | +| 26 | `src/runtime.ts` | `createAgentDevice`: backend + `bindCommands` | orchestration | KEEP — agent device facade | +| 27 | `src/commands/index.ts` | `bindCommands` → `bindInteractionCommands` | orchestration | KEEP — command catalog boundary | +| 28 | `src/commands/interaction/runtime/index.ts` | `bindInteractionCommands`: `press` → `pressCommand` | orchestration | KEEP — nine-command binding surface | +| 29 | `src/daemon/interaction/internal/interaction-touch-android-readiness.ts` | `runWithAndroidDialogReadinessCheck` wraps `run` | policy | KEEP — blocking-dialog readiness gate | +| 30 | `src/commands/interaction/runtime/interactions.ts` | `pressCommand` → `tapCommand`: resolve target, `backend.tap` | orchestration | KEEP — tap command execution | +| 31 | `src/commands/interaction/runtime/resolution.ts` | `resolveInteractionTarget` applies guards, returns the point | translation | KEEP — tap target resolution | +| 32 | `packages/contracts/src/interactor-operation-binding.ts` | `interactorFor` resolves the local interactor | translation | KEEP — shared interactor resolution seam | +| 33 | `src/platform-runtime-local-application-interactors.ts` | `resolve()` lazy-imports `core/interactors` | pass-through | KEEP — lazy host interactor port | +| 34 | `src/core/interactors.ts` | `getLocalInteractor` → `getPlugin(platform).createInteractor` | pass-through | KEEP — provider vs local interactor dispatch | +| 35 | `src/core/interactors/register-builtins.ts` | android plugin entry lazy-imports `./android.ts` | pass-through | KEEP — platform plugin registry | +| 36 | `src/core/interactors/android.ts` | `createAndroidInteractor`: `tap` → `pressAndroid` | orchestration | KEEP — interactor construction | +| 37 | `packages/platform-android/src/input-actions.ts` | `pressAndroid` builds the `shell input tap` argv | translation | KEEP — shared argv construction | +| 38 | `packages/platform-android/src/adb.ts` | `runAndroidAdb` → `resolveAndroidAdbExecutor` | pass-through | KEEP — single adb executor seam | +| 39 | `packages/platform-android/src/adb-provider-scope.ts` | `resolveAndroidAdbExecutor` picks the device-scoped executor | policy | KEEP — executor scoping | +| 40 | `packages/platform-android/src/adb-failure.ts` | `withAdbFailureHints` wraps the executor call | pass-through | KEEP — typed adb failure classification | +| 41 | `src/platform-runtime-android-adb-host.ts` | `execSerialAdb` issues the serial-scoped tap invocation | **terminal (adapter)** | n/a (terminal) | + +The terminal's invocation is spawned through host-kit's shared `runCmd` +(`packages/host-kit/src/internal/command.ts`, the generic subprocess adapter shared with the +`snapshot` route — counted there, not a separate hop here). Precedent: at the baseline +measurement the terminal was the route-level issue point (`adb.ts`) even though the spawn lived +in the executor below it; the same boundary is `adb-host` at HEAD (the named root file bound +only by its named composition module per ADR Decision §1). + +**Admission fan-out (not hops).** The android owner's per-request `inspectFacts` +(`packages/platform-android/src/runtime.ts`) executes 23 distinct +`packages/contracts/src/*-runtime.ts` `*RuntimeOperationFacts` builders as side-calls: +`application-lifecycle`, `alert`, `app-event`, `app-switcher`, `audio-probe`, `back`, +`clipboard`, `element-text`, `focus`, `gesture`, `home`, `keyboard`, `orientation`, `perf`, +`screenshot`, `scroll`, `selector-observation`, `settings`, `snapshot`, `touch`, `type-text`, +`tv-remote`, `viewport` (each `-runtime.ts`). + +**Response path (not hops).** 8 files execute only after the platform call: +`src/daemon/interaction/internal/interaction-touch-response.ts`, +`src/daemon/interaction/internal/interaction-common.ts`, +`src/daemon/interaction-outcome-policy.ts`, `src/daemon/request-finalization.ts`, +`src/daemon/session-event-log.ts`, `src/daemon/session-snapshot.ts`, +`src/daemon/recording-gestures.ts`, `src/daemon/deferred-interaction-outcome.ts`. + +**Forks not followed**: `request-platform-provider-context.ts` (the call is guarded by the +router's early return for a bare local daemon — zero runtimes + non-web), +`platform-runtime/request-providers.ts` (only under `requestPlatformProviders.run`, not called +for local), `lease-registry.ts` (`isHumanControlMutation` is false for an agent-surface press), +the `@ref`/direct-iOS fast paths in `interaction-touch-press.ts`, the provider-adb arm in +`adb-provider-scope.ts`, and the error-path arms in `adb-failure.ts`/`adb-host.ts`. + +## `snapshot` / iOS Simulator: HTTP entry → AX bridge `socket.write` (primary) / runner `fetch` (fallback) + +The route forks at `packages/platform-apple/src/snapshot-route.ts` (`route.capture`, +`isEligible` + per-generation circuit breaker): the in-simulator AX bridge is the primary arm; +the XCTest runner is the fallback (chosen when ineligible, on AX acquisition failure, or when +the single-flight target discovery — `TARGET_DISCOVERY_WAIT_MS` bounded wait in +`snapshot-target.ts` — has not settled for this capture; the probe keeps running and later +captures join it). +Shared prefix 30 files; primary arm 47 distinct (30 + 17); fallback arm 49 distinct +(30 + 19); 66 distinct files across the union. The request spine's side-calls — the idle-reap +reset, the auth comparison, the cancellation gate, and the Android freshness guard/clear +(`src/daemon/session-snapshot-freshness.ts`, a guard read plus a post-capture clear that no-ops +on iOS) — run on this prefix as siblings of the continuation and are not counted. + +### Shared prefix (entry → fork) + +| # | File | Hand-off | Role | Delete-test | +|---|------|----------|------|-------------| +| 1 | `src/daemon/server/http-server.ts` | parses `/rpc`, resolves the token; calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | +| 2 | `src/daemon/server/daemon-runtime.ts` | `handleRequest`: in-flight count, cancels idle reap, `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | +| 3 | `src/daemon/request-router.ts` | auth, flag guards, scope creation, locked execution, handler chain | orchestration | KEEP — request admission + dispatch core | +| 4 | `packages/host-kit/src/internal/diagnostics.ts` | `withDiagnosticsScope` wraps the request task | pass-through | KEEP — per-request diagnostic attribution | +| 5 | `src/request/device-inventory-context.ts` | `withDeviceInventoryContext` wraps the task | pass-through | KEEP — request-scoped device inventory context | +| 6 | `src/core/dispatch-resolve.ts` | `withResolveTargetDeviceCacheScope` wraps the task | orchestration | KEEP — per-request target-resolution cache | +| 7 | `src/daemon/request-execution-scope.ts` | `createRequestExecutionScope`; `runLocked`/`runAdmitted` | orchestration | KEEP — scoped execution + lock/claim admission | +| 8 | `src/provider-device-runtime.ts` | `withProviderDeviceRuntimeScope` (`AsyncLocalStorage.run`) | pass-through | KEEP — provider-runtime scope boundary | +| 9 | `src/daemon/request-handler-chain.ts` | `runRequestHandlerChain` routes `snapshot` to `runSnapshotHandler` | orchestration | KEEP — command routing | +| 10 | `src/daemon/handlers/snapshot.ts` | `handleSnapshotCommands` routes the plain arm | policy | KEEP — snapshot command routing | +| 11 | `src/daemon/snapshot-runtime.ts` | `dispatchSnapshotViaRuntime` → `params.execute` | pass-through | KEEP — snapshot execute seam (route tests pin it) | +| 12 | `src/daemon/snapshot-command-runtime.ts` | resolves the bound capture; wires session/backend; runs the op | orchestration | KEEP — capture wiring + recording | +| 13 | `src/daemon/deferred-interaction-outcome.ts` | `resolveDeferredInteractionOutcome` before a fresh capture | policy | KEEP — pending-outcome settlement (may short-circuit) | +| 14 | `src/daemon/snapshot-capture.ts` | `resolveSnapshotScope` + the capture attempt | orchestration | KEEP — capture attempt + scope resolution | +| 15 | `src/daemon/snapshot-session.ts` | `resolveSessionDevice` for the capture | translation | KEEP — snapshot session/device resolution | +| 16 | `src/daemon/snapshot-runtime-binding.ts` | `resolveBoundSnapshotCaptureRuntime`: admit-then-bind | policy | KEEP — ADR 0019 §9 admit-then-bind seam | +| 17 | `src/daemon/session-runtime-admission.ts` | `admitRuntimePlan` (fan-out site) / `requireRuntimeBinding` | policy | KEEP — runtime plan fact admission | +| 18 | `src/daemon/request-runtime-binding.ts` | `bindDevice`: per-device-cached `gateway.bind` | orchestration | KEEP — device runtime binding | +| 19 | `src/platform-runtime-gateway.ts` | `loadLocal` → `loadHost` → `loadRuntime` | orchestration | KEEP — gateway boundary | +| 20 | `src/platform-runtime.ts` | wires the apple module, supplies `loadHost` | pass-through | KEEP — declared ADR §1/§2 registry boundary | +| 21 | `src/platform-runtime-operation-host.ts` | `createPlatformRuntimeHost` assembles the host | orchestration | KEEP — host composition | +| 22 | `packages/platform-apple/src/index.ts` | `loadRuntime` pairs the plugin with the runtime module | pass-through | KEEP — declared ADR §2 `loadRuntime` boundary | +| 23 | `packages/platform-apple/src/runtime.ts` | `bind` → `bindAppleSnapshotRuntime` | orchestration | KEEP — apple runtime owner | +| 24 | `packages/platform-apple/src/runtime-snapshot.ts` | `bindAppleSnapshotRuntime` wires the AX + fallback closures | orchestration | KEEP — AX/runner wiring (tests pin it) | +| 25 | `packages/platform-apple/src/snapshot-route.ts` | `createAppleSnapshotRoute`; `isEligible` + circuit breaker | policy | KEEP — AX/runner route + breaker (the fork) | +| 26 | `src/runtime.ts` | `createAgentDevice`: the bound runtime object | orchestration | KEEP — agent device facade | +| 27 | `src/commands/index.ts` | `bindCommands` binds the command catalog | orchestration | KEEP — command catalog boundary | +| 28 | `src/commands/runtime-types.ts` | `bindRuntimeCommands` wraps the runtime into closures | pass-through | REMOVABLE — thin closure wrapper; depth lives in the commands | +| 29 | `src/commands/capture/runtime/snapshot.ts` | `snapshotCommand`: parses input, calls `runtime.backend.captureSnapshot` | orchestration | KEEP — capture command implementation | +| 30 | `packages/contracts/src/snapshot-runtime.ts` | the `captureSnapshot` op → AX route or interactor fallback | pass-through | KEEP — cross-layer contract seam (ownership refusal + signal join) | + +### Arm A — primary (in-simulator AX bridge), 17 files + +| # | File | Hand-off | Role | Delete-test | +|---|------|----------|------|-------------| +| 1 | `packages/platform-apple/src/snapshot-target.ts` | `resolveTarget`: simctl + device-list probe | translation | KEEP — target identity/generation contract | +| 2 | `packages/platform-apple/src/core/apps-simctl.ts` | `runSimctl`: simctl argv + execution | translation | KEEP — shared simctl argv convention | +| 3 | `packages/platform-apple/src/core/simctl.ts` | `buildSimctlArgsForDevice`: device-set arg scoping | translation | KEEP — simulator device-set args | +| 4 | `packages/platform-apple/src/core/tool-provider.ts` | `runXcrun`: scoped tool executor | adapter | KEEP — scoped tool-execution injection seam | +| 5 | `packages/platform-apple/src/os/macos/host-provider.ts` | `createLocalAppleMacOsHostProvider` at module init | adapter | KEEP — local macOS tool provider | +| 6 | `packages/platform-apple/src/snapshot-process.ts` | `readSnapshotTargetProcessStartTime` via `ps` | pass-through | KEEP — stale-target staleness guard | +| 7 | `packages/platform-apple/src/snapshot-source-facade.ts` | lazy-imports `snapshot-source/adapter.ts`; `acquire` closure | pass-through | KEEP — lazy-load startup isolation | +| 8 | `packages/platform-apple/src/snapshot-source/adapter.ts` | `acquire`: validate, prepare, `manager.request` | orchestration | KEEP — AX bridge acquisition pipeline | +| 9 | `packages/platform-apple/src/snapshot-source/limits.ts` | `resolveSnapshotSourceLimits` per acquire | policy | KEEP — acquisition limit budgets | +| 10 | `packages/platform-apple/src/snapshot-source/deadline.ts` | `createSnapshotSourceDeadline` per acquire | policy | KEEP — acquisition deadline budget | +| 11 | `packages/platform-apple/src/snapshot-source/cache.ts` | `ensureSnapshotBridgeBinary`: cache admit, cold clang compile | policy | KEEP — bridge binary cache + cold compile | +| 12 | `packages/platform-apple/src/snapshot-source/lifecycle.ts` | per-udid session lock; `ensureSession`, `exchange` | orchestration | KEEP — bridge session lifecycle + lock | +| 13 | `packages/platform-apple/src/snapshot-source/protocol.ts` | frame encode/decode; envelope parse | translation | KEEP — wire frame codec (tests pin frames) | +| 14 | `packages/host-kit/src/internal/command.ts` | `runCmd`/`runCmdBackground`: bridge spawn | adapter | KEEP — shared subprocess adapter | +| 15 | `packages/host-kit/src/internal/process.ts` | `hostProcessId`/`readProcessStartTime`: lock ownership | adapter | KEEP — lock owner process identity | +| 16 | `packages/host-kit/src/internal/host-file.ts` | socket/bridge dir file ops | adapter | KEEP — bridge socket file ops | +| 17 | `packages/platform-apple/src/snapshot-source/transport.ts` | `roundTripSnapshotBridge`: `socket.write` at :107 | **terminal (adapter)** | n/a (terminal) | + +### Arm B — fallback (XCTest runner, steady state), 19 files + +| # | File | Hand-off | Role | Delete-test | +|---|------|----------|------|-------------| +| 1 | `src/platform-runtime-local-application-interactors.ts` | `resolve()` lazy-imports `core/interactors` | pass-through | KEEP — lazy host-port seam | +| 2 | `src/core/interactors.ts` | `getLocalInteractor` → `getPlugin(platform).createInteractor` | pass-through | KEEP — local-owner authority vs provider scoping | +| 3 | `src/core/platform-plugin-registry.ts` | `getPlugin` lookup; throws `UNSUPPORTED_PLATFORM` | policy | KEEP — platform plugin registry | +| 4 | `packages/platform-apple/src/interactor.ts` | `captureAppleRunnerSnapshot` builds the command payload | orchestration | KEEP — interactor contract implementation | +| 5 | `packages/platform-apple/src/core/runner-client.ts` | re-export entry for the runner client | pass-through | KEEP — client composition root | +| 6 | `packages/platform-apple/src/runner/client.ts` | `createAppleRunnerClient` at module init | orchestration | KEEP — runner client factory | +| 7 | `packages/platform-apple/src/runner/runner-client.ts` | `runAppleRunnerCommand`: read-only retry, `waitForRunner` | policy | KEEP — runner command dispatch + retry | +| 8 | `packages/platform-apple/src/runner/host.ts` | `bindAppleRunnerHost`: retry/diagnostic/Deadline delegates | adapter | KEEP — injected host seam (runner tests stub it) | +| 9 | `packages/platform-apple/src/runner/runner-command-traits.ts` | `isReadOnlyRunnerCommand`: snapshot is read-only | policy | KEEP — command trait classification | +| 10 | `packages/platform-apple/src/runner/runner-contract.ts` | command id, request signal, active assertion | policy | KEEP — runner request contract | +| 11 | `packages/host-kit/src/internal/retry.ts` | `retryWithPolicy`: bounded retry loop | policy | KEEP — retry policy | +| 12 | `packages/kernel/src/keyed-lock.ts` | `withKeyedLock` for session/lease locks | policy | KEEP — named lock primitive | +| 13 | `packages/platform-apple/src/runner/runner-lifecycle.ts` | `executeRunnerCommand`: session snapshot, `ensureRunnerSession` | orchestration | KEEP — runner command execution | +| 14 | `packages/platform-apple/src/runner/runner-session.ts` | steady `resolveReusableRunnerSession` → `executeRunnerCommandWithSession` | orchestration | KEEP — session reuse + preflight | +| 15 | `packages/platform-apple/src/runner/runner-recycle-ledger.ts` | ledger key, touched-session check/mark | policy | KEEP — recycle budget ledger | +| 16 | `packages/platform-apple/src/runner/runner-disposal.ts` | `isRunnerProcessAlive` liveness check | policy | KEEP — lease liveness + cleanup | +| 17 | `packages/platform-apple/src/runner/runner-command-route.ts` | `resolveRoute`: loopback/usbmux endpoint | translation | KEEP — usbmux/network route model | +| 18 | `packages/platform-apple/src/runner/runner-startup-transport.ts` | `waitForRunner` → `tryRunnerRoute` → `tryRunnerEndpoints` | orchestration | KEEP — readiness wait (network route) | +| 19 | `packages/platform-apple/src/runner/runner-transport.ts` | `fetchWithTimeout` → `fetch` to the runner at :90 | **terminal (adapter)** | n/a (terminal) | + +Re-entry: arm B #4 enters `packages/platform-apple/src/index.ts` (shared #22) via +`applePlugin.createInteractor` — counted once. + +**Admission fan-out (not hops).** The apple owner's per-request `inspectFacts` +(`packages/platform-apple/src/runtime.ts`) executes the same 23 +`packages/contracts/src/*-runtime.ts` `*RuntimeOperationFacts` builders as the android owner +above, as side-calls of the admission step. + +**Response path (not hops).** 22 files execute only after the platform call: +`src/daemon/ref-frame.ts`, `src/daemon/session-action-recorder.ts`, +`src/daemon/snapshot-quality-latch.ts`, `src/daemon/request-finalization.ts`, +`src/daemon/session-event-request.ts`, `src/daemon/session-snapshot.ts` (lineage), +`src/daemon/sparse-fallback-screenshot.ts`, `src/core/snapshot-state.ts`, +`packages/contracts/src/capture.ts`, `packages/platform-apple/src/runner/snapshot-presentation.ts`, +`src/snapshot/ios-snapshot-runtime.ts`, the `packages/capture-kit/src/ios-snapshot-engine/` +modules (engine, invariants, geometry, geometry-policy, projection, graph, +runner-presentation), `packages/capture-kit/src/ios-snapshot-planning.ts`, +`packages/capture-kit/src/snapshot-quality-verdict.ts`, +`packages/contracts/src/snapshot-scope.ts`, +`packages/platform-apple/src/snapshot-source/tree.ts`. + +**Forks not followed**: `request-platform-provider-context.ts` (router early return for a bare +local daemon), `platform-runtime/request-providers.ts`, `lease-registry.ts` +(`isHumanControlMutation` false for an agent-surface snapshot), the `alert`/`settings`/`diff`/ +`wait` arms in `handlers/snapshot.ts`, the macOS surface-capture arms, the +`selector-*`/`custom-actions-*` plan arms in `snapshot-runtime-binding.ts`, and the arm B +**cold-start chain** (steady-state session exists): `startRunnerSessionWithLease` in +`runner-session.ts` into ~15 `packages/platform-apple/src/runner/` files +(`runner-lease`, `runner-adoption`, `runner-artifact`, `runner-artifact-env`, `runner-cache`, +`runner-cache-metadata`, `runner-device-set`, `runner-process-launch`, `runner-listener-ready`, +`runner-io`, `runner-source`, `runner-session-types`, `runner-macos-products`, `runner-icon`, +`runner-xctestrun` products) plus host-kit `exec`/`atomic-file`/`host-process` — the xctestrun +build, process launch, and listener-ready observation. ## Why this differs from the ADR's 38/29 @@ -100,47 +273,60 @@ The façade PRs the plan sheet named as a candidate explanation (#2178, #2222, # **before** `e624ef9d3f` — the commit the ADR cites for its 38/29 measurement — so they cannot be why that count is higher than this one; they were already in effect when 38/29 was recorded. -This trace lands at 23 hops for `press`/Android and 24 hops for `snapshot`/iOS. The ADR text names -no ordered chain, command/artifact, or counting definition for 38/29, so the discrepancy cannot be -resolved against it. The most likely explanation is a different counting unit (for example, named -exports or every static/type import touched rather than distinct production files on the call -path). **Treat 38 and 29 as superseded by the auditable 23/24 measured here**, not as a second data -point to reconcile. +This trace lands at 41 hops for `press`/Android and 47/49 (per arm) for `snapshot`/iOS. The ADR +text names no ordered chain, command/artifact, or counting definition for 38/29, so the +discrepancy cannot be resolved against it. The most likely explanation is a different counting +unit (for example, named exports or every static/type import touched rather than distinct +production files on the call path). **Treat 38 and 29 as superseded by the auditable numbers +measured here**, not as a second data point to reconcile. + +## Delta vs the 23/24 measured at `132ffe1da` + +Both routes kept their old files (nothing on the old routes was dropped: the snapshot +`register-builtins.ts` lookup moved from call-time to module-load registration, with +`platform-plugin-registry.ts` taking the call-time role). The growth is four clusters: + +1. **Request-scope wrapper layer** (6 new spine files, both routes): `daemon-runtime.ts`, + host-kit `diagnostics`, `device-inventory-context.ts`, `dispatch-resolve.ts`, + `request-execution-scope.ts`, `provider-device-runtime.ts`. The old 3-file router spine + (`http-server` → `request-router` → `request-handler-chain`) is now 9 files: diagnostics + scoping, inventory/resolution contexts, execution scope/locks, and provider-scope isolation + moved into the router's request path. The auth token comparison, the cancellation gate, and + the idle-reap timer reset ride on that path as side-calls, not hops. +2. **AgentDevice command layer** (press rows 24–30, snapshot shared 26–29): commands now + execute through the bound AgentDevice surface (`src/runtime.ts`, `src/commands/**`, + `interaction-runtime.ts`) between the daemon dispatch and the platform interactor. +3. **adb host split + claim gate** (press rows 23, 39–41): `device-claim-admission.ts`, + `adb-provider-scope.ts`, `adb-failure.ts`, and the named-root `platform-runtime-android-adb-host.ts` + terminal (the package `adb.ts` is now a pass-through executor seam). +4. **Snapshot dual arms** (snapshot): the in-simulator AX bridge became the primary capture + path (arm A: `snapshot-source/*`, target/process/simctl resolution, host-kit process + adapters), the XCTest runner demoted to fallback (arm B), and the runner protocol deepened + (`runner-command-traits`, `runner-contract`, `runner-recycle-ledger`, + `runner-startup-transport`) while the presentation work moved to the response path + (capture-kit engine). ## The ≤14 target -Splitting each traced route into stages: - -- **Router spine** (shared by every command): hops 1-3, `http-server.ts` → `request-router.ts` → - `request-handler-chain.ts`. 3 files, fixed. -- **Admission/binding** (resolve the session's device, admit the plan, obtain a bound - capture/tap closure): press hops 4-10 (7 files, 2 substantive); snapshot hops 4-9 (6 files, 3 - substantive). -- **Gateway resolution** (the declared `platform-runtime.ts` boundary and its `loadRuntime` - pairing): hops 11-13/10-13 (3 files, 2 substantive) on both routes — these files are pinned as - boundaries by Decision §1/§2 and do not collapse regardless of target. -- **Interactor resolution** (the local-interactor plugin lookup, a second object graph parallel - to the operations bind above): 3 pass-through/thin files on both routes. -- **Platform glue + terminal call**: press hops 17-23 (7 files, 4 substantive incl. terminal); - snapshot hops 20-24 (5 files, 5 substantive incl. terminal, because the XCTest runner protocol - — session lifecycle, recycle budget, transport — has no Android equivalent to `adb`'s - single-process-call shape). - -Collapsing every pass-through and thin file in the admission/binding and interactor-resolution -stages down to one hop each (folding `interaction-touch-prepare.ts` into `interaction-touch-press.ts`, -`runtime-admission.ts` into `touch-runtime.ts`, the interactor-resolution three-file chain into -one lookup, etc.) removes roughly 9 files from `press` (23 → ~14) and roughly 9 from `snapshot` -(24 → ~15), leaving mostly the substantive hops plus the fixed spine and declared boundaries. - -That arithmetic makes ≤14 plausible for `press`/Android under an aggressive but not obviously -wrong collapsing plan. For `snapshot`/iOS it lands at ~15, and closing that last gap means cutting -into the runner-protocol's substantive hops (`runner-client.ts` / `runner-lifecycle.ts` / -`runner-session.ts` / `runner-transport.ts`), which carry retry policy, session-recycle budgeting, -and session-cache bookkeeping that is not obviously waste. No accepted plan collapses those four -into fewer files today. - -**Verdict: ≤14 is a proposed target, unverified.** It is in the right neighborhood for `press` -under a plausible (not yet accepted) collapsing plan, and is not clearly reachable for `snapshot` -without a decision to fold runner-protocol mechanics that are load-bearing, not incidental. Treat -14 as a discussion anchor for the maintainer decision this ADR section defers to, not as a derived -number. +Re-derived at HEAD under the same collapsing logic the earlier revision applied (fold every +pass-through hop with no kept depth): + +- **`press`/Android — no longer plausible.** The fixed request spine alone (rows 1–9) is 9 + files, and every one of them either passes the deletion test with kept depth (diagnostics + attribution, inventory/resolution context, execution scope, provider scope) or is a declared + boundary. Every remaining pass-through hop keeps depth (declared boundaries, the interactor + resolution seam, the adb executor seam and typed failure classification), so reaching ≤14 + would require cutting 27 files that are cross-cutting daemon concerns or load-bearing + bindings, not press-specific waste. +- **`snapshot`/iOS — not reachable.** The shared prefix alone is 30 files, and the primary arm + adds 16 non-terminal hops of the AX-bridge protocol (binary cache, session lock, wire codec, + deadline budgets); the fallback arm's runner protocol (retry, contract, recycle ledger, + readiness) remains load-bearing. The only REMOVABLE hop is shared row 28 + (`commands/runtime-types`, thin closure wrapper): 47/49 → 46/48. + +**Verdict: the ≤14 target is superseded.** The audited routes at `27a97ee619` are 41 +(`press`/Android) and 47/49 (`snapshot`/iOS per arm), and the deletion test proves a single +distinct removable hop across both routes (`commands/runtime-types.ts`). Collapsing that one is +the only hop-depth reduction the audit endorses; any further target needs a decision to fold +cross-cutting request-scope wrappers, which is outside this route's ownership. Treat 14 as a +historical discussion anchor, not as a derived number. diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 8894af5ec2..13db372f16 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -875,23 +875,24 @@ baseline and are open for revision or rejection. target: ≤ 60. `src/daemon/**` is a permanent zone under R65; the target is about what remains there, not about retiring the directory. -**Entry-to-platform hop count.** Corrected 2026-09-03: the counting definition, ordered chains, -and commit for this measurement are in -[`0019-end-state-hop-trace.md`](./0019-end-state-hop-trace.md), which supersedes the number -below. A file-by-file re-trace at HEAD measured 23 hops for `press`/Android and 24 hops for -`snapshot`/iOS. The previously stated 38/29 named no ordered chain, counting definition, or -artifact and does not reproduce; treat it as superseded, not as a second data point. -`src/platform-runtime.ts` (the -immutable registry construction) and each platform façade's `loadRuntime` pairing are declared -boundaries under Decision §1 and §2, not pass-through layers — they stay in any hop count -regardless of target. Everything else on the traced path is a pass-through candidate only insofar -as R13's named-facet enumeration and the -`kernel < contracts < host-kit < capture-kit < provision-kit < platform/provider/daemon` direction -already allow collapsing it; a hop that exists only to satisfy that direction is not waste. -Proposed target: ≤ 14 hops each — the hop-trace artifact derives this as plausible for `press` -under an aggressive, not-yet-accepted collapsing plan, and not clearly reachable for `snapshot` -without cutting into load-bearing XCTest runner-protocol mechanics; **treat 14 as a proposed -target, unverified**, not a derived number. +**Entry-to-platform hop count.** Corrected 2026-09-03, re-measured for #2278 at `27a97ee619`: +the counting definition, ordered chains, hop roles, and commit for this measurement are in +[`0019-end-state-hop-trace.md`](./0019-end-state-hop-trace.md), which supersedes the numbers +below. A file-by-file re-trace at HEAD measured 41 hops for `press`/Android and 47/49 hops +(shared 30 plus 17/19 per arm) for `snapshot`/iOS, which is now a dual-arm route (in-simulator +AX bridge primary, XCTest runner fallback). The previously stated 38/29 named no ordered chain, +counting definition, or artifact and does not reproduce; treat it as superseded, not as a +second data point. `src/platform-runtime.ts` (the immutable registry construction) and each +platform façade's `loadRuntime` pairing are declared boundaries under Decision §1 and §2, not +pass-through layers — they stay in any hop count regardless of target. Everything else on the +traced path is a pass-through candidate only insofar as R13's named-facet enumeration and the +`kernel < contracts < host-kit < capture-kit < provision-kit < platform/provider/daemon` +direction already allow collapsing it; a hop that exists only to satisfy that direction is not +waste. The deletion test at HEAD proves a single distinct removable hop across both routes +(`commands/runtime-types.ts`); the ≤ 14 target derived at the earlier measurement is +**superseded and not reachable** without a decision to fold cross-cutting request-scope +wrappers, which is outside the traced routes' ownership. Treat 14 as a historical discussion +anchor, not as a proposed commitment. **Zones still under `src/` that this ADR expects to leave, and their package status:** diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md new file mode 100644 index 0000000000..f100b8397e --- /dev/null +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -0,0 +1,119 @@ +# ADR 0022: Daemon — Platform Runtime Coupling Audit and Ownership Ratchets + +## Status + +Accepted (2026-09-06). Implements the audit deliverables of issue #2278. The classification +inventory and the structural no-regrowth checks are gate-enforced; this ADR records the decisions +and points at the gates rather than restating the inventory. + +## 1. Context + +The platform-package migration removed direct production dependencies from `src/daemon/**` to +concrete platform code (R65: zero such imports in every form). What it did not prove is that +every remaining platform-aware responsibility belongs in the daemon. At the audit baseline +(`658f822c`) nine daemon files held 13 production imports of root `src/platform-runtime-*.ts` +composition modules; at the measurement commit (`27a97ee619`) the count is 14 edges across the +same nine files (one file holds an import and a re-export of the same symbol). + +`SessionState` write ownership was ratcheted (R7/R10), but broad state reads and `SessionStore` +authority were not. The ADR 0019 hop trace recorded routes of ~24 files with no accepted budget +or owning issue. #2278 audited all four concerns at `27a97ee619`. + +## 2. Decision + +1. **Every daemon import of a root `src/platform-runtime-*.ts` module is classified as exactly + one of three categories**, and the classification is owned by the machine-readable inventory + `DAEMON_PLATFORM_RUNTIME_EDGES` in + [`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts): + - **Composition-essential** — assembling neutral runtime implementations at the process root. + - **Daemon-policy-essential** — request admission, session ownership, conflicts, locking, + cancellation, teardown ordering, artifact publication, or response/event semantics. + - **Leaked platform mechanics** — platform-family operations or state that should be owned + behind a deeper runtime interface or adapter; only this category creates implementation + work (child issues below). + The gate rule **R76 `daemon-platform-runtime-inventory`** (in `scripts/layering/check.ts`) + fails the layering check on any unclassified edge, on symbol-set drift between a file and its + inventory entry, and on stale entries. Observed red against a planted unclassified import + before acceptance. + +2. **Per-edge outcomes at `27a97ee619`.** Four edges are composition-essential and stay + (`daemon-runtime.ts` → `platform-runtime.ts` and → `platform-runtime-host-diagnostics.ts`; + `device-claim-owner-recovery.ts` → `platform-runtime.ts`; + `device-ready.ts` → `platform-runtime-device-ready.ts` — process-root assembly of the + composed gateway, host diagnostics, owner recovery, and device readiness). The remaining + nine edges are leaked platform mechanics and are owned by: + - **#2332** — Apple runner session observation behind a semantic port + (`request-recording-health.ts`, `session-device-resolution.ts`, `ios-app-session-hint.ts` + → `inspectAppleRunnerSession` / `resolveSoleForegroundIosApp`). + - **#2333** — lifecycle participation of platform resource owners + (`daemon-runtime.ts` → `platform-runtime-apple-runner-owner.ts`, + `platform-runtime-resource-cleanup.ts`, and the dynamic + `platform-runtime-operation-host.ts` import). + - **#2334** — open-target planning separated from platform mechanics + (`session-open-prepare.ts`, `session-selector-dispatch.ts` → + `platform-runtime-open-target.ts`), blocked by #2332. + - **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the + selector seam those issues own; coordination was posted there rather than opening a second + selector producer. + +3. **Session state and store authority are measured as a symbol-level overlay and ratcheted + on the handler-owned slice.** At `27a97ee619` the overlay is 112 `SessionState` shape edges + and 68 `SessionStore` authority edges repo-wide, of which 14 shape / 22 authority files are + handler-owned (`src/daemon/handlers/**`). Gate rule **R75 `session-authority-overlay`** + (reference measured via `measureRatchets` in `scripts/layering/ratchet-reference.ts`) holds + the handler-owned file sets at or under the merge-base: handler code may lose files from + either set, never gain them. The ratchet is added now that the edges are classified; + cross-module reads outside `src/daemon/handlers/**` are owned by the logical-module + declarations (R7/R10) and are not ratcheted here. + +4. **The entry-to-platform hop trace was re-run with hop roles** + (policy / orchestration / translation / adapter / pass-through + terminal) and a deletion + test per pass-through/translation hop. The updated artifact is + [`0019-end-state-hop-trace.md`](0019-end-state-hop-trace.md): 41 hops for `press`/Android and + 47/49 per arm for the now dual-arm `snapshot`/iOS route (shared 30 + AX bridge 17 / runner + fallback 19). The deletion test proves a single distinct removable hop + (`commands/runtime-types.ts`); the request-spine guards (auth comparison, cancellation gate, + idle-reap timer reset, Android freshness guard/clear) are side-calls, not hops. The earlier + ≤14 target is superseded and not reachable without folding cross-cutting request-scope + wrappers, which the audit does not endorse. + +5. **Per-audit-area decisions.** Apple selector/session observation: deepen an existing + interface (#2332). Runtime lifecycle participation: deepen through the existing lifecycle + phases, no generic hook bag (#2333). Open-target planning: separate plan/result from + platform mechanics, one construction path preserved (#2334). Session state/store authority: + keep the current access shape, ratchet the handler-owned slice (R75). Route depth: record the + re-traced routes; collapse only the proven pass-through hops (none undertaken in this + change). + +## 3. Rationale + +- **The inventory is a registry, not a prose copy.** #2278's 13 imports are a review inventory, + not 13 presumed violations; a neutral runtime interface may be the correct owning seam. A + machine-readable table with per-edge categories is the only form of the decision that the + layering gate can enforce, and it is the form that stays honest as edges move: drift and + staleness are failures, not warnings. +- **Category 1 and 2 stay; category 3 deepens.** Splitting the nine leaked edges into three + child issues (instead of one refactor) keeps each independently reviewable and preserves the + existing behavior each surface depends on. The selector edge is deliberately not a child of + #2332: #2273/#2274 already own the selector seam, and a second producer would duplicate + snapshot-producer policy. +- **R75 ratchets the handler slice, not the whole overlay.** The repo-wide 112/68 edge set + includes owning-module reads that are correct (session lifecycle reading session state). + Ratcheting the whole set would freeze legitimate movement; ratcheting the handler-owned set + targets the exact regression #2278 flagged — handlers accreting state shape and store + authority — and is the slice the fresh audit found unowned. +- **The hop target is retired, not missed.** The 23/24 routes measured at `132ffe1da` grew to + 41 / 47-49 because the request-scope wrapper layer, the AgentDevice command layer, the adb + host split, and the dual-arm snapshot capture all landed on the traced paths. Every retained + hop now carries a documented role and, for pass-through/translation hops, a kept-depth or + removable verdict; the single proven-removable hop is recorded as the only endorsed + collapse. + +## 4. Enforced by + +- R76 `daemon-platform-runtime-inventory` and R75 `session-authority-overlay` in + `scripts/layering/check.ts` (both observed red against planted violations before acceptance). +- R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice. +- R65 for the concrete-platform-import ban this audit builds on. +- Child issues #2332, #2333, #2334 (and #2273/#2274 for the selector seam) for the category-3 + implementation work. diff --git a/docs/adr/README.md b/docs/adr/README.md index 84842bc5e9..3b078f9af1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,6 +23,7 @@ | [0019 Request-Bound Platform Runtime](0019-request-bound-platform-runtime.md) | platform-package boundaries/composition, device discovery, runtime facts/facets, request binding, provider ownership, platform-shaped session resources, durable reattachment, daemon-handler migration | | [0020 Composable Recorded Fragments (Proposed)](0020-composable-recorded-fragments.md) | lifecycle-free recorded fragment capture/composition, entry guards, fragment-local addresses/digests, staleness, and native `.ad`/Maestro composition | | [0021 Host — Simlock-Backed Managed Device Allocation and the Host Supervisor](0021-host-simlock-managed-device-allocation.md) | local-first Simlock-managed execution, Host identity/admin boundaries, shape allocation, durable managed-device lease mapping, lifecycle ownership, and supervised maintenance | +| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the R76 edge classification inventory, R75 session-authority ratchet, entry-to-platform hop routes and roles | ADRs record *why*; the registries and gates they describe are the living source of truth — when prose and a registry disagree, the registry wins and the ADR needs a follow-up.