Files
callstack__agent-device/docs/adr/0023-end-state-hop-trace.md
Michał Pierzchała b2b084d2e1 docs: fix phantom specifiers, the duplicate ADR 0019, and the Node floor (#2533)
AGENTS.md routed request cancellation/progress and diagnostics to
`@agent-device/capture-kit` subpaths that no package exports; both live in
`@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also
named `@agent-device/contracts` as an importable seam although that package
publishes no root export, and claimed `src/daemon/handlers/session.ts` was over
budget after that extraction already landed at 242 lines.

Two ADRs carried number 0019. The hop trace has its own claims to make, so it now
numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022.

The Node floor split was undocumented: `engines.node` stays at 22.12 because CI
installs the published tarball on that floor, while contributors need 22.13 for the
pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and
the web backend's Node 24 requirement.

Extend the agent-guidance contract to resolve every `@agent-device/*` specifier
AGENTS.md names against the owning package's `exports`, root included, so neither a
phantom subpath nor a phantom package root can route an agent to a module that does
not exist.
2026-09-13 10:15:57 +02:00

32 KiB
Raw Permalink Blame History

ADR-0023: end-state entry-to-platform hop trace

Backs the "Entry-to-platform hop count" paragraph in 0019-request-bound-platform-runtime.md. 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.

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

  • Unit: one distinct production .ts file entered on the call path, counted once even if 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, 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

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

The façade PRs the plan sheet named as a candidate explanation (#2178, #2222, #2232) all merged 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 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

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.