diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 41f82f85ba..a825c62316 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -2,1011 +2,1004 @@ ## Status -Completed (2026-08-27). Accepted for staged adoption on 2026-08-09, with a successful checkpoint on -2026-08-10 and broader-migration governance added on 2026-08-11. The historical checkpoint and -per-unit evidence in sections 7–10 explain how the transition was kept shippable; they no longer -authorize or schedule migration work. - -Amended 2026-08-26 while closing the Wave 6 residue: `host` covers host-scoped platform work through -a narrow neutral typed service, not diagnostics alone. Host diagnostics, daemon-owner resource -cleanup, and managed host tooling share the execution category because none binds a request-scoped -device runtime; their domain services remain separate rather than forming a generic host grab bag. - -Amendment proposed 2026-09-23 alongside the web press-shape refusal: it states in section 3 that a -modifier on an admitted operation is not a second operation and therefore has no fact cell, which is -what a per-shape refusal on the owning leaf mechanic rests on. Without that sentence the next -contributor reasonably reaches for a per-shape fact key, which the operation-facts mapped type cannot -express. - -Amendment proposed 2026-09-14 by [ADR 0024](./0024-screen-recording-provable-signal.md) for the -`screen-recording` kind only; see the note at the head of section 5. It takes effect when ADR 0024 -is accepted. - -Completed 2026-08-27 through #2070–#2072. Every descriptor now declares `none`, `host`, -`inventory`, or `device-runtime`; capability buckets and the legacy execution shape are gone. -Production `packages/daemon/src/**` has zero dependencies on concrete platform implementations, enforced by -R65. The per-command cutover table was retired after completion as required by section 6; R66 now -enforces the permanent facts-only admission and runtime-proof invariants without naming historical -routes or handler functions. - -The #2082 extraction completes the physical ownership boundary: all six platform-family -implementations and their family-owned tests live behind private package exports, `src/platforms` -is retired, and the former R3 folder seam is gone. R13 now owns concrete platform-package import -direction and implementation laziness; the `retired-platforms-zone` gate rejects any attempt to -recreate the old root path. +Completed (2026-08-27). Accepted for staged adoption on 2026-08-09. +The adoption checkpoint on 2026-08-10 succeeded, and broader-migration governance was added on 2026-08-11. +The historical evidence in sections 7–10 explains how the transition stayed shippable. +It no longer authorizes or schedules migration work. +Git history retains the completed process history. + +The amendment of 2026-08-26 closed the Wave 6 residue. +It extends `host` beyond diagnostics to host-scoped platform work through a narrow, neutral, typed service. +Host diagnostics, daemon-owner resource cleanup, and managed host tooling share this execution category because none binds a request-scoped device runtime. +Their domain services remain separate and do not form a generic host grab bag. + +An amendment proposed on 2026-09-23 accompanies the web press-shape refusal. +It states in section 3 that a modifier on an admitted operation is not a second operation and therefore has no fact cell. +A per-shape refusal on the owning leaf mechanic rests on that statement. +Without that statement, the next contributor can reasonably reach for a per-shape fact key. +The operation-facts mapped type cannot express such a key. + +[ADR 0024](./0024-screen-recording-provable-signal.md) proposes an amendment dated 2026-09-14 for the `screen-recording` kind only. +The note at the start of section 5 describes it. It takes effect when ADR 0024 is accepted. + +Completion on 2026-08-27 through #2070–#2072 removed capability buckets and the legacy execution shape. +Every descriptor declares `none`, `host`, `inventory`, or `device-runtime`. +R65 enforces zero concrete platform dependencies from production `packages/daemon/src/**`. +The per-command cutover table was retired after completion, as section 6 required. +R66 now enforces the permanent facts-only admission and runtime-proof invariants without historical route or handler names. + +The #2082 extraction completes the physical ownership boundary: all six families' implementations and family-owned tests are behind private package exports. +The `src/platforms` path and former R3 folder seam are retired. +R13 governs the import direction of concrete platform packages and implementation laziness. +The `retired-platforms-zone` gate rejects any attempt to recreate the old root path. ## Rules at a glance -- Daemon device-execution code depends on platform-neutral contracts. Concrete device mechanics for - the six canonical families live in private `@agent-device/platform-*` packages; each family owns - its implementation and family-specific tests, while shared install-source tests are root-owned - under `src/__tests__/`. The root composition module and R13-governed named consumer facades are - the only production static value-import sites. -- The platform registry is **metadata-eager and implementation-lazy**. Cheap family identity, - inventory entrypoints, and static fact declarations may load at composition time; platform - mechanics and process-lived helper managers load only when discovery or the first binding for that - family needs them. -- The registry selects one local runtime owner per canonical platform family. Support is claimed and - tested for the exact platform leaf, device kind/backend, and provider mode; family ownership never - implies uniform leaf support. -- Command descriptors declare one typed execution shape: inventory use, or platform-neutral required - device operations with separately declared preferred optimizations and fact-conditional semantic - operations. Runtime owners report - device-specific facts and expose behavior-bearing facets; platform and provider implementations - never name commands. -- `RequestExecutionScope.bindDevice(device, use)` resolves provider ownership, validates the facts - and concrete facets, and returns a runtime type narrowed to the proven runtime use. Handlers cannot - use undeclared facets or repair missing proof with casts or non-null assertions. -- Provider ownership is first-class and fail-closed. An owning provider may supply transport to a - platform runtime or implement facets directly; missing required behavior never falls through to a - local owner. -- Process-lived helper managers, request-lived device bindings, and daemon-session durable resources - have distinct lifetimes. Helper instances remain lazy, identity-bound, invalidatable, and - idle-stoppable. Disposing a request binding never stops a healthy helper or adopted resource. -- Durable work has two representations: a process-local live handle and a versioned, persistable - descriptor through which the same runtime owner can recover. Live session state may own a neutral - facet handle under R7; only the descriptor and neutral metadata enter persisted recovery state. +- Daemon device-execution code depends on platform-neutral contracts. + Private `@agent-device/platform-*` packages own mechanics and family-specific tests for the six canonical families. + Root `src/__tests__/` owns shared install-source tests. + The root composition module and R13-governed named consumer facades are the only production static value-import sites. +- The registry is **metadata-eager and implementation-lazy**. + Cheap family identity, inventory entrypoints, and static fact declarations may load at composition time. + Mechanics and process-lived helper managers load only when discovery or the first binding for that family needs them. +- The registry selects one local runtime owner per canonical family. + Support claims and tests cover the exact platform leaf, device kind/backend, and provider mode. + Family ownership does not imply equal support for every leaf. +- Command descriptors declare one typed execution shape: inventory use or platform-neutral required device operations. + A device-operation declaration declares any preferred optimizations and fact-conditional semantic operations separately. + Runtime owners report device-specific facts and expose behavior-bearing facets. + Platform and provider implementations never name commands. +- `RequestExecutionScope.bindDevice(device, use)` resolves provider ownership, validates facts and concrete facets, and returns a runtime narrowed to proven use. + Handlers cannot use undeclared facets. Casts and non-null assertions cannot repair absent proof. +- Provider ownership is first-class and fail-closed. + An owning provider may supply transport to a platform runtime or implement facets directly. + Missing required behavior never causes a local-owner fallback. +- Process-lived helper managers, request-lived device bindings, and daemon-session durable resources have separate lifetimes. + Helpers remain lazy, identity-bound, invalidatable, and idle-stoppable. + Disposal of a request binding never stops a healthy helper or adopted resource. +- Durable work has a process-local live handle and a versioned, persistable descriptor for recovery by the same owner. + Under R7, live session state may own a neutral facet handle. + Persisted recovery state contains only the descriptor and neutral metadata. Concrete platform process objects never cross the seam. -- A descriptor has exactly one explicitly declared platform-execution shape: `none`, `host`, - `inventory`, or `device-runtime`. `none` declares the absence of platform execution. `host` declares - host-scoped platform work through a neutral typed domain service: diagnostics, owner cleanup, or - managed tooling. The command binds no device runtime as its own execution shape, and any - device-runtime leg rides another command's declared use. -- During adoption, the first real slices paused for an explicit continue/revise/stop checkpoint. - Later units were move-dominated: platform mechanics left root `src/` for platform packages and - exceptional shipped-size growth required an individual justification. -- Evidence is tiered by what a unit imports: request-scoped device units prove facts, operations, - and parity cells; only durable-resource units carry the section 4–5 lifecycle evidence. -- A handler binds once with its execution use. Admission, `capabilities`, and doctor questions use - side-effect-free facts inspection; required-only declarations are the default, a preferred - operation requires a recorded measurement, and a conditional operation requires parity evidence - explaining why correctness depends on the owner fact. -- Cross-cutting facets land with their first consuming command unit. Daemon startup recovery is - evidence-gated, daemon shutdown is two-phase (detach, then stop), and session-teardown steps - belong to their owning domains — there is no generic lifecycle-hook API. +- Each descriptor explicitly declares exactly one shape: `none`, `host`, `inventory`, or `device-runtime`. + `none` declares no platform execution. + `host` declares host-scoped platform work through a neutral typed domain service for diagnostics, owner cleanup, or managed tooling. + It binds no device runtime as its own shape. + Any device-runtime part uses another command's declared use. +- Evidence is tiered by what a unit imports. + Request-scoped device units prove facts, operations, and parity cells. + Only durable-resource units carry the lifecycle evidence in sections 4–5. +- A handler binds once with its execution use, subject to the section 9 observation exception. + Admission, `capabilities`, and doctor use facts inspection without side effects. + Required-only declarations are the default. + Preferred operations require recorded measurements. A conditional operation requires parity evidence that explains why correctness depends on the owner fact. +- A cross-cutting facet accompanies its first consuming command unit. + Daemon startup recovery is gated by evidence. Shutdown has two phases: detach, then stop. + Each domain owns its session-teardown steps. There is no generic lifecycle-hook API. ## Context -ADR 0009 deliberately introduced a shallow `PlatformPlugin`: it wrapped the existing platform -branches, preserved their lazy imports byte-for-byte, and moved each daemon column only after a -parity gate proved equivalence. That was the right migration shape. It stopped platform conditionals -from spreading, but its app-log, recording, performance, and provider facets still return tags or -predicates that the daemon maps back to concrete implementations. +ADR 0009 deliberately introduced a shallow `PlatformPlugin` around existing platform branches. +It preserved lazy imports byte-for-byte and moved each daemon column only after a parity gate proved equivalence. +That was the correct migration shape. It stopped the spread of platform conditionals. +However, its app-log, recording, performance, and provider facets still return tags or predicates that the daemon maps to concrete implementations. -The canonical registry now has six families: `apple`, `android`, `harmonyos`, `vega`, `linux`, and -`web`. HarmonyOS supplied the additional real adapter pressure that the earlier platform-package -checkpoint deliberately deferred. With this many families, a tag-to-implementation map leaves the -daemon owning every platform variation even when the platform conditional has moved behind a -plugin. +The registry has six canonical families: -The replacement must preserve the properties paid for by the existing architecture: daemon-owned -request policy, provider-first integration scenarios, Apple-family leaf modeling, kept-hot helper -performance, interaction guarantee honesty, pre-mutation ref-frame expiry, normalized errors, -package direction, and CLI cold-start laziness. +- `apple` +- `android` +- `harmonyos` +- `vega` +- `linux` +- `web` + +HarmonyOS supplied the additional real adapter pressure that the earlier platform-package checkpoint deliberately deferred. +With this many families, a tag-to-implementation map leaves the daemon responsible for every platform variation, even with the conditional behind a plugin. + +The replacement must preserve these architectural properties: + +- Daemon-owned request policy. +- Provider-first integration scenarios. +- Apple-family leaf models. +- Kept-hot helper performance. +- Accurate interaction guarantees. +- Ref-frame expiry before mutation. +- Normalized errors. +- Package direction. +- Lazy CLI cold starts. ## Decision ### 1. Platform modules provide behavior through one composition seam -Each canonical family has one private `packages/platform-` workspace package named -`@agent-device/platform-`. Its façade exposes cheap metadata plus lazy implementations of a -`DeviceInventorySource` and `DeviceRuntimeGateway`; the implementations behind those entrypoints own -platform tool invocation, helper protocols, runtime facts, output parsing, recovery mechanics, and -other native details. A platform package contains only that family's implementations and mechanics, -never daemon orchestration, command policy, or cross-family defaults. - -The root composition module, `src/platform-runtime.ts`, constructs immutable -inventory/runtime registries and injects a composed inventory gateway plus provider-first runtime -gateway into daemon request execution. Its private -`src/platform-runtime/request-providers.ts` implementation submodule owns the cross-family request -provider resolver table and wrapper ordering; only the canonical root may load it, and it remains -lazy until a request enters a provider scope. Daemon device-execution modules import the canonical -root interface or runtime contracts only. -Shared runtime interfaces and neutral data types live in `@agent-device/contracts`. In production, -only that composition module, its one R13-governed private implementation submodule, or the -R13-governed consumer seams under `src/core/interactors/` may statically import concrete platform -package roots; approved runtime hosts use deferred or type-only root imports, and named Apple -facades expose only their governed domain seam. Reusable types do not leak through type-only -platform imports. Platform packages may import contracts, kernel/domain packages, -and explicitly injected host capabilities; they may not import daemon requests or responses, mutable -session state, command catalogs/grammar, root implementation files, sibling platform packages, or raw -process primitives outside the shared host-command port. R13 applies these rules to static, type-only, -dynamic, and re-export edges; package-owned tests may import their own public façade. Contracts may -depend on kernel vocabulary but never on concrete platform packages or daemon implementation types. - -The Android family mechanics now live entirely in `@agent-device/platform-android`. Its root façade -remains metadata-only and lazy; the package exports exactly two implementation facets: `./mechanics` -for named Android behavior consumed by root/core/SDK code and package-owned tests, and `./adb-host` -for the host port bound only by `src/platform-runtime-android-adb-host.ts`. The old -`src/platforms/android` family tree and root shims are deleted. R13 enumerates these two facets and -rejects any additional Android subpath or daemon production import, so package closure and host -authority stay explicit rather than being restored through compatibility paths. - -Durable-capture mechanics shared by more than one implementation live in the private -`@agent-device/capture-kit` workspace package, with the enforced direction -`kernel < contracts < capture-kit < platform/provider/daemon`. Contracts retains pure vocabulary and -plan models; process supervision, live-handle implementations, recovery helpers, runtime codecs, and -capture parsers do not live there. `capture-kit` is a domain package for durable capture, not a generic -platform-common package, and it preserves the package façades' implementation-lazy loading boundary. -Its introduction carries the normal workspace-package compliance surface: `check:affected` -selection, R11/R13 package enumeration, and the composite typecheck project list. - -> **Amendment (#2082): the substrate below the platform families.** Retiring the shared -> `src/utils` and `src/platforms` root surfaces (so the family trees can move behind their -> exports maps) forces every shared file onto a declared domain owner, and the paragraph above -> is amended to name that layout rather than let capture-kit absorb it: +Each canonical family has one private `packages/platform-` workspace package named `@agent-device/platform-`. +Its façade exposes cheap metadata and lazy implementations of `DeviceInventorySource` and `DeviceRuntimeGateway`. +These implementations own platform tool calls, helper protocols, runtime facts, output parsers, recovery mechanics, and other native details. +A platform package contains only its family's implementations and mechanics. +It contains no daemon orchestration, command policy, or cross-family defaults. + +The root composition module, `src/platform-runtime.ts`, constructs immutable inventory/runtime registries. +It injects a composed inventory gateway and provider-first runtime gateway into daemon request execution. +Its private `src/platform-runtime/request-providers.ts` submodule owns the cross-family resolver table for request providers and the wrapper order. +Only the canonical root may load this submodule. +It remains lazy until a request enters a provider scope. +Daemon device-execution modules import only the canonical root interface or runtime contracts. + +`@agent-device/contracts` owns shared runtime interfaces and neutral data types. +In production, only these sites may statically import concrete platform package roots: + +- The composition module. +- Its one private implementation submodule governed by R13. +- The consumer seams under `src/core/interactors/` governed by R13. + +Approved runtime hosts use deferred or type-only root imports. +Named Apple facades expose only their governed domain seam. +Type-only platform imports do not expose reusable types. +Platform packages may import contracts, kernel/domain packages, and explicitly injected host capabilities. +They may not import these items: + +- Daemon requests or responses. +- Mutable session state. +- Command catalogs or grammar. +- Root implementation files. +- Sibling platform packages. +- Raw process primitives outside the shared host-command port. + +R13 covers static, type-only, dynamic, and re-export edges. +Package-owned tests may import their own public façade. +Contracts may depend on kernel vocabulary. +Contracts never depend on concrete platform packages or daemon implementation types. + +`@agent-device/platform-android` owns all Android family mechanics. +Its root façade remains metadata-only and lazy. +It exports exactly two implementation facets: + +- `./mechanics` exposes named Android behavior to root/core/SDK consumers and package-owned tests. +- `./adb-host` exposes the host port that only `src/platform-runtime-android-adb-host.ts` binds. + +The old `src/platforms/android` tree and root shims are deleted. +R13 enumerates these facets and rejects additional Android subpaths or daemon production imports. +These restrictions preserve package closure and explicit host authority without compatibility paths. + +The private `@agent-device/capture-kit` package owns durable-capture mechanics shared by more than one implementation. +The enforced direction is `kernel < contracts < capture-kit < platform/provider/daemon`, subject to the substrate amendment below. +Contracts retains pure vocabulary and plan models. +It contains no process supervision, live-handle implementations, recovery helpers, runtime codecs, or capture parsers. +`capture-kit` owns the durable-capture domain; it is not a generic platform-common package. +It preserves lazy implementation loads through package façades. +Its workspace compliance includes `check:affected` selection, R11/R13 package enumeration, and the composite typecheck project list. + +> **Amendment (#2082): the substrate below the platform families.** +> Retirement of `src/utils` and `src/platforms` requires a declared domain owner for every shared file. +> Family trees move behind their export maps. The ownership below amends the preceding paragraph to name that layout, instead of letting capture-kit absorb it. > -> - `@agent-device/host-kit` owns mechanics that act on the host machine, and nothing else. Each -> export is one narrow capability port, not a category barrel: `command` (running host -> commands), `process` (observing and owning host processes), `diagnostics`, `retry` -> (deadline/backoff/sleep), `archive` (bounded extraction and byte limits), `file` (atomic -> publishes, locks, path resolution), `request` (request-scoped cancellation and progress), -> `transport` (line framing, lazy HTTP/body mechanics, and constant-time secret comparison), -> and `version` (the installed version off disk). Modules under `src/internal/` are reachable -> only through a port, and a port may only hold mechanics a consumer of that capability -> needs — the eager-closure row per port is what keeps that honest. -> - A helper that touches no process, file, or environment is not host mechanics and does not -> belong here: pure record readers, config-source values, result text, memoization, async -> scoping, coordinate validation, and device-scope parsing live in `@agent-device/kernel` -> beside its other primitives. -> - `@agent-device/capture-kit` owns capture, snapshot, and recording behavior — PNG tooling, -> screenshot density and pixel diffing, snapshot occlusion, mobile snapshot semantics, -> quality verdicts and backend capability tables. Snapshot *behavior* is capture domain, not -> contracts vocabulary, and host mechanics are host-kit's, not capture-kit's. -> - `@agent-device/provision-kit` owns the provisioning domain — everything that gets a device -> and app ready to run: install-artifact acquisition (local paths, archives, guarded network -> downloads), host toolchain readiness probing, device boot-failure classification, and -> app-resolution caching. Platform packages may import provision-kit; provision-kit may not -> import a platform package or be imported by capture-kit (both directions planted red in the -> layering suite). -> - The enforced direction is `kernel < contracts < host-kit < capture-kit < provision-kit < -> platform/provider/daemon`. -> - Contracts stays vocabulary, plan models, and pure classification with no process, -> filesystem, or timer mechanics (the existing planted-red gate); platform-specific parsing -> stays with its family package and reaches legacy callers through composition, never by a -> family importing another owner's internals. +> - `@agent-device/host-kit` owns only mechanics that act on the host machine. +> Each export is one narrow capability port, not a category barrel: +> - `command`: host command execution. +> - `process`: host process observation and ownership. +> - `diagnostics`. +> - `retry`: deadlines, backoff, and sleep. +> - `archive`: bounded extraction and byte limits. +> - `file`: atomic publication, locks, and path resolution. +> - `request`: request-scoped cancellation and progress. +> - `transport`: line frames, lazy HTTP/body mechanics, and constant-time secret comparison. +> - `version`: the installed version from disk. +> Modules under `src/internal/` are reachable only through a port. +> A port may contain only mechanics that a consumer of that capability needs. +> The eager-closure row for each port enforces this restriction. +> - A helper that touches no process, file, or environment is not host mechanics and does not belong in host-kit. +> These helpers belong to `@agent-device/kernel`, beside its other primitives: +> - Pure record readers. +> - Config-source values. +> - Result text. +> - Memoization. +> - Async scopes. +> - Coordinate validation. +> - Device-scope parsers. +> - `@agent-device/capture-kit` owns capture, snapshot, and recording behavior: +> - PNG tooling. +> - Screenshot density and pixel differences. +> - Snapshot occlusion. +> - Mobile snapshot semantics. +> - Quality verdicts. +> - Backend capability tables. +> Snapshot behavior belongs to capture, not contracts vocabulary. +> Host mechanics belong to host-kit, not capture-kit. +> - `@agent-device/provision-kit` owns preparation of a device and app for execution: +> - Install-artifact acquisition from local paths, archives, and guarded network downloads. +> - Host toolchain readiness probes. +> - Device boot-failure classification. +> - App-resolution caches. +> Platform packages may import provision-kit. +> Provision-kit may not import a platform package. +> Capture-kit may not import provision-kit. +> The layering suite tests both prohibited directions with planted violations. +> - The enforced direction is `kernel < contracts < host-kit < capture-kit < provision-kit < platform/provider/daemon`. +> - Contracts retains vocabulary, plan models, and pure classification. +> The existing planted-red gate prohibits process, filesystem, and timer mechanics there. +> Each family package owns its platform-specific parsers. +> Legacy callers reach them through composition. +> Families never import another owner's internals. > -> Enforcement: each substrate package's exported subpaths are pinned in -> `package-boundaries.test.ts` (widening fails the gate), the contracts mechanics gate stays -> planted red, and the `retired-platforms-zone` rule rejects every production, test, or fixture -> file under the former `src/platforms` path. - -The Apple package root is composition-only: it exposes the inventory module, runtime module, -shutdown loader, and platform plugin. Synchronous consumers use named domain facets instead of a -second compatibility surface on the root. - -The Apple XCUITest runner client is a durable platform-owned implementation facet colocated -inside `packages/platform-apple` as the `src/runner/` subtree (#2040) — Apple mechanics belong to -the Apple package. R13 models the package by enumeration rather than by exception sprawl: the family -exports its root façade plus thirteen named domain/mechanics facades — `./app-lifecycle`, -`./app-resolution`, `./debug-symbols`, `./doctor`, `./install-artifact`, `./macos`, -`./perf`, `./physical-device`, `./runner-owner`, `./runner/operations`, `./simctl`, `./simulator`, and -`./tool-provider` — as well as the `./runner` -subpath. The named facades replace root-only access for synchronous domain consumers without a -broad compatibility barrel; R13 pins the exact export set and allowed consumer seams. The -`./runner` façade subpath is the seam through which daemon and root consumers reach runner mechanics -directly today; the host-bound runner client and its test host stay package-internal (the `apple-runner` vitest -project installs the test host from the package's own `__tests__/host-setup.ts`); the facet owns its cache files and usbmux sockets (the -ambient-host rule exempts exactly that subtree), while raw process primitives stay banned — host -authority still enters through one focused injected port (`AppleRunnerHost`: process execution, -diagnostics, retry, probes, locks, foreground Apple tooling, physical-device control) constructed by -exactly one composition root. No current issue owns migrating the runner's direct consumers behind -the composition gateway; if such a migration retires them, the `./runner` seam narrows with it, but -the facet itself is the intended ownership model, not a temporary exception. -Mechanics-facet declarations are explicit per family: the Apple runner and Android mechanics/host -facets are enumerated above, and a new family adds its own named facet only with an owning consumer -and evidence. - -Canonical family, `AppleOS`, public-leaf, and selector identity remain declared in -`@agent-device/kernel/device`. Platform-module metadata references one canonical family; during -coexistence the legacy plugin registry derives its family identity from the same declaration rather -than becoming a second root. - -Host command execution, diagnostics, progress, and resolved native assets enter through focused host -capabilities. A platform package receives only the capabilities its implementation needs. Shared -logic moves to an honest domain package only after more than one implementation needs it; there is -no `platform-common`, generic host-utilities package, universal command dispatcher, or generic -platform-resource union. +> `package-boundaries.test.ts` pins each substrate package's exported subpaths. Additional subpaths fail the gate. +> The contracts mechanics gate retains its planted-red test. +> `retired-platforms-zone` rejects every production, test, or fixture file under the former `src/platforms` path. + +The Apple package root serves composition only. +It exposes the inventory module, runtime module, shutdown loader, and platform plugin. +Synchronous consumers use named domain facets without a second root compatibility surface. + +The Apple XCUITest runner client is a durable implementation facet owned by the Apple platform. +It resides in `packages/platform-apple/src/runner/` (#2040). +R13 enumerates the root façade, the `./runner` subpath, and thirteen named domain/mechanics facades: + +- `./app-lifecycle` +- `./app-resolution` +- `./debug-symbols` +- `./doctor` +- `./install-artifact` +- `./macos` +- `./perf` +- `./physical-device` +- `./runner-owner` +- `./runner/operations` +- `./simctl` +- `./simulator` +- `./tool-provider` + +Named facades replace root-only access for synchronous domain consumers without a broad compatibility barrel. +R13 pins the exact exports and permitted consumer seams. +The `./runner` façade subpath is the seam through which daemon and root consumers currently reach runner mechanics directly. +The host-bound runner client and its test host remain package-internal. +The `apple-runner` vitest project installs the test host from the package's `__tests__/host-setup.ts`. +The facet owns its cache files and usbmux sockets. +The ambient-host rule exempts exactly this subtree. Raw process primitives remain prohibited. +Host authority still enters through one focused injected port, `AppleRunnerHost`. +Exactly one composition root constructs that port. +The port supplies these host capabilities: + +- Process execution. +- Diagnostics. +- Retry. +- Probes. +- Locks. +- Foreground Apple tooling. +- Physical-device control. + +No current issue owns migration of the runner's direct consumers behind the composition gateway. +If a migration retires those consumers, the `./runner` seam narrows with them. +The facet is the intended ownership model, not a temporary exception. +Each family explicitly declares mechanics facets. +A new family adds a named facet only with an owning consumer and evidence. +The Apple runner and Android mechanics/host facets are enumerated above. + +`@agent-device/kernel/device` declares canonical family, `AppleOS`, public-leaf, and selector identity. +Platform-module metadata references one canonical family. +During coexistence, the legacy plugin registry derives family identity from this same declaration, without a second root. + +Focused host capabilities supply command execution, diagnostics, progress, and resolved native assets. +A platform package receives only the capabilities it needs. +Shared logic moves to an honest domain package only after more than one implementation needs it. +There is no `platform-common`, generic host-utilities package, universal command dispatcher, or generic platform-resource union. #### Implementation-laziness -Static composition must not make every daemon or CLI startup evaluate every platform implementation. -The registry eagerly loads only metadata-only package façades. A façade keeps heavy leaf imports and -helper-manager construction behind lazy entrypoints: +Static composition must not evaluate every platform implementation at each daemon or CLI startup. +The registry eagerly loads only metadata-only package façades. +Façades defer heavy leaf imports and helper-manager construction through lazy entrypoints: -- discovery loads mechanics only for a family whose inventory is requested; -- binding loads mechanics only for the selected runtime owner; -- process-lived helper managers are created on first use, not while composing the registry; and -- loading one family does not load an unrelated family's implementation graph. - -Composition may capture inert configured paths eagerly. Filesystem/tool probing, asset preparation or -building, helper construction, and facts requiring a selected-owner probe remain lazy. The structural -suite must prove that unrelated implementations are not evaluated before selected discovery/binding, -and the built artifact retains its startup measurement. Passing a startup threshold alone is not a -substitute for preserving the loading shape; the tracking issue owns the exact probe and planted-red -procedure. - -### 2. Runtime use joins facts and narrows the bound runtime +- Discovery loads mechanics only for a family whose inventory the request selects. +- A binding loads mechanics only for the selected runtime owner. +- Process-lived helper managers are created on first use, not during registry composition. +- A family load does not load an unrelated family's implementation graph. -`CommandDescriptor` remains the command declaration root. Its runtime-use declaration has a typed set -of required platform-neutral operations and may separately name preferred optimizations or -fact-conditional semantic operations. Commands whose use depends on normalized input first produce a -discriminated execution plan that retains literal required/preferred/conditional types. The three -operation sets are pairwise disjoint. Preferred operations may improve execution but are never -necessary for command correctness. A conditional operation is different: an owner that advertises -it must provide it because that owner's semantically complete path depends on the observation; an -owner that reports it unavailable must have a parity-proven complete path through the required -operations. This prevents a correctness-bearing owner variation from being mislabeled as an -optimization or made unconditionally required across owners without that semantic source. - -Inventory commands have a separate `inventoryUse` declaration. `devices` calls the composed -`DeviceInventoryGateway`, which selects canonical family sources and provider-owned inventory sources -from the request selectors; it neither invents a synthetic device nor calls `bindDevice`. Its -coverage denominator is all six local family inventory sources, every provider inventory source, and -selector/public-device projection parity. Inventory results become ordinary `DeviceInfo` values only -after their source has classified the canonical family and required leaf identity. - -A runtime owner reports exhaustive facts for the exact device shape and returns capability-cohesive -facets with normalized semantic inputs and typed outcomes. Facet inputs never contain a command name, -`DaemonRequest`, `DaemonResponse`, `SessionState`, CLI flags, or an opaque command payload. A missing -facet plus a typed unavailability fact represents unsupported behavior; implementations do not ship -stubs that throw `unsupported` after binding. Runtime-use keys identify individual semantic -operations, not whole facet namespaces, so selecting one operation does not expose undeclared sibling -operations from the same facet. - -**A shape of an operation is not an operation and gets no fact cell.** Runtime facts speak per -semantic operation; a modifier applied to an admitted operation — a press that is double, held, or -repeated — is not a second operation and must not be given a fact key, because the facts map is a -mapped type over operation names and a phantom operation satisfies neither the required nor the -preferred runtime-use shape. Such a shape is refused by the owning leaf mechanic being absent, with a -typed reason and a hint, at the point the shape is requested. Absence of a whole admitted -operation's member remains an ownership bug and fails closed with its own typed reason; it is never -the mechanism that expresses an unsupported shape. - -**A subject of an operation may carry its own fact.** When one operation serves a closed vocabulary -that owners serve unevenly — `setSetting` over the settings catalog — its available fact may name a -fact per subject and an `unlisted` refusal for every other subject. Admission takes the subject the -request names and refuses an unavailable one exactly as it refuses an unavailable operation, before -binding. A subject is the thing the request names (which setting); a shape of the request (a -coordinate form, a permission target) still belongs to the owning leaf mechanic, as above. - -`RequestExecutionScope.bindDevice(device, use)` is the trust choke point. It: - -1. resolves the exact local or provider runtime owner; -2. checks every required operation and classifies each preferred and conditional operation against - facts for the platform leaf, device kind/backend, and provider mode; -3. creates or reuses one request binding for that ownership-qualified device; -4. verifies that every required operation and every preferred or conditional operation advertised as - available has a concrete facet implementation; an advertised operation with no implementation is - a runtime-contract error; and -5. returns a selected operation projection: required operations are non-optional, declared preferred - and conditional operations are optional and present only when available, and undeclared operations - are inaccessible. - -The cached broad runtime remains private to `RequestExecutionScope`; narrowing does not intersect a -wide optional aggregate that would still expose undeclared facets. The descriptor and its specialized -handler share one non-widened declaration, and a widened generic descriptor carries no static proof. -A compile-time contract test proves the selected projection. A structural -**runtime-facet-narrowing gate** covers every runtime-migrated handler owner and rejects attempts to -manufacture required-operation proof with assertions or optional admission. Optional access is -permitted only for descriptor-declared preferred or conditional operations. The tracking issue owns the gate -implementation and its required planted violation. - -Absence or failure of a preferred path may change optimization/path disclosure, not whether the -semantic command can execute. Failure falls back to the complete required path only through a typed -reason and an explicit descriptor/ADR 0011 path classification; it is never a generic `catch` -fallback. Helper/session reuse hidden inside one required operation remains that facet's implementation -detail and follows ADR 0002 rather than becoming a daemon-visible preferred operation. - -Conditional operations do not inherit that fallback rule. Their absence is valid only when the owner -fact reports them unavailable and parity evidence establishes the required path for that owner cell. -When the fact reports one available, omission of its implementation is a runtime-contract failure; -the handler must not silently continue through a path known to lose the observation. - -Family registration and support coverage are separate gates. The immutable registry owns each of the -six canonical families exactly once. Before a command cuts over, an independent parity artifact -freezes its legacy supported/unsupported cells and hints. Runtime-fact scenarios expand canonical -kernel-owned family/leaf fixtures over device kinds/backends and provider modes; a new runtime may -not make a difficult legacy-supported cell disappear. Behavior changes require a separate decision. - -Apple coverage uses an exhaustive `AppleOS` fixture table and explicitly exercises iOS simulator and -physical backends where they differ, iPadOS, tvOS focus-only/no-coordinate behavior, macOS desktop, -visionOS deferred or supported cells, and the watchOS unsupported/discovery-absence sentinel. Every -fixture matches exactly one family. A loop over six families with one generic `platform: 'apple'` -device is not leaf coverage. +Composition may eagerly capture inert configured paths. +These activities remain lazy: -### 3. Provider ownership is exact and fail-closed +- Filesystem and tool probes. +- Asset preparation or builds. +- Helper construction. +- Fact probes that require a selected owner. -For one device, provider resolution has three outcomes: +The structural suite must prove that unrelated implementations do not evaluate before selected discovery or a selected binding. +The built artifact retains its startup measurement. +A passing startup threshold alone does not replace preservation of the loading shape. +The tracking issue owns the exact probe and planted-red procedure. -1. exactly one provider owns it: bind that provider-owned runtime; -2. no provider owns it: bind the local family runtime; or -3. more than one provider owns it: fail with an ownership-contract error. +### 2. Runtime use joins facts and narrows the bound runtime -Only providers that declare device-runtime ownership participate in this arbitration. A narrow tool, -transport, app-log, or recording provider is an injected dependency of the selected local/provider -runtime; it does not become a second runtime owner or compete in the owner count. +`CommandDescriptor` remains the command declaration root. +Its runtime use declares typed required platform-neutral operations. +It may separately name preferred optimizations and fact-conditional semantic operations. +If normalized input determines use, the command first produces a discriminated execution plan. +The plan retains literal required/preferred/conditional types. +The three operation sets are pairwise disjoint. +Preferred operations may improve execution. Command correctness never requires them. + +A conditional operation is different. +If an owner advertises a conditional operation, the owner must provide it because the owner's semantically complete path depends on that observation. +If an owner reports it unavailable, parity evidence must prove a complete path through required operations. +This distinguishes correctness that depends on the owner from optimization. +It also avoids an unconditional requirement for owners without that semantic source. + +Inventory commands declare separate `inventoryUse`. +`devices` calls the composed `DeviceInventoryGateway`. +The gateway selects canonical family sources and provider-owned inventory sources from request selectors. +It creates no synthetic device and does not call `bindDevice`. +Its coverage denominator is all six local family inventory sources, every provider inventory source, and parity for selector and public-device projections. +An inventory result becomes an ordinary `DeviceInfo` only after its source classifies the canonical family and required leaf identity. + +A runtime owner reports exhaustive facts for the exact device shape. +It returns capability-cohesive facets with normalized semantic inputs and typed outcomes. +Facet inputs never contain these items: + +- A command name. +- `DaemonRequest`. +- `DaemonResponse`. +- `SessionState`. +- CLI flags. +- An opaque command payload. + +A missing facet and typed unavailability fact represent unsupported behavior. +Implementations do not provide stubs that throw `unsupported` after a binding. +Runtime-use keys identify individual semantic operations, not complete facet namespaces. +Selection of one operation exposes no undeclared sibling operation. + +**A shape of an operation is not an operation and gets no fact cell.** +Runtime facts describe each semantic operation. +A modifier on an admitted operation, such as a double, held, or repeated press, is not a second operation. +It must not be given a fact key. +The reason is that the facts map is a mapped type over operation names, and a phantom operation satisfies neither the required nor the preferred runtime-use shape. +If the owning leaf mechanic is absent, the shape is refused with a typed reason and a hint, at the point the shape is requested. +Absence of the member for a whole admitted operation remains an ownership bug and fails closed with its own typed reason. +That absence is never the mechanism that expresses an unsupported shape. + +**A subject of an operation may carry its own fact.** +One operation can serve a closed vocabulary that owners serve unevenly, such as `setSetting` over the settings catalog. +In that case, its available fact may name a fact per subject and an `unlisted` refusal for every other subject. +Before a binding, admission uses the requested subject. +It refuses an unavailable subject exactly as it refuses an unavailable operation. +A subject names the requested thing, such as which setting. +A request shape, such as a coordinate form or permission target, still belongs to the owning leaf mechanic described above. + +`RequestExecutionScope.bindDevice(device, use)` is the trust choke point. +It performs these steps: + +1. Resolve the exact local or provider runtime owner. +2. Check every required operation against facts for the platform leaf, device kind/backend, and provider mode. +3. Classify preferred and conditional operations against those facts. +4. Create or reuse one request binding for that ownership-qualified device. +5. Verify concrete implementations for all required operations and all preferred or conditional operations advertised as available. +6. If an advertised operation lacks an implementation, report a runtime-contract error. +7. Return the selected operation projection. + +Required operations are non-optional in that projection. +Declared preferred and conditional operations are optional and present only when available. +Undeclared operations are inaccessible. + +The broad cached runtime remains private to `RequestExecutionScope`. +The narrowed type does not intersect a wide optional aggregate that would still expose undeclared facets. +The descriptor and its specialized handler share one non-widened declaration. +A widened generic descriptor supplies no static proof. +A compile-time contract test proves the selected projection. +The structural **runtime-facet-narrowing gate** covers every runtime-migrated handler owner. +It rejects attempts to manufacture required-operation proof with assertions or optional admission. +Optional access is permitted only for descriptor-declared preferred or conditional operations. +The tracking issue owns the gate implementation and required planted violation. + +Absence or failure of a preferred path may change optimization or path disclosure. +It may not change whether the semantic command can execute. +Fallback to the complete required path requires a typed reason and explicit descriptor/ADR 0011 path classification. +A generic `catch` fallback is prohibited. +Helper/session reuse inside one required operation remains internal to that facet under ADR 0002. +It does not become a daemon-visible preferred operation. + +Conditional operations do not use that fallback rule. +Their absence is valid only when the owner fact reports them unavailable and parity evidence establishes the required path for that owner cell. +If the fact reports availability, an absent implementation is a runtime-contract failure. +The handler must not silently use a path known to lose the observation. + +Family registration and support coverage use separate gates. +The immutable registry owns each of the six canonical families exactly once. +Before a command cuts over, an independent parity artifact freezes the command's legacy supported/unsupported cells and hints. +Runtime-fact scenarios expand canonical kernel-owned family/leaf fixtures across device kinds/backends and provider modes. +A new runtime may not remove a difficult legacy-supported cell. Behavior changes require a separate decision. + +Apple coverage uses an exhaustive `AppleOS` fixture table. +It explicitly exercises these cells: + +- iOS simulator and physical backends where they differ. +- iPadOS. +- tvOS focus-only/no-coordinate behavior. +- macOS desktop. +- visionOS deferred or supported cells. +- The watchOS unsupported/discovery-absence sentinel. + +Each fixture matches exactly one family. +A six-family loop with one generic `platform: 'apple'` device does not prove leaf coverage. -Provider ownership is authoritative. If the owning provider lacks a required fact or facet, admission -returns the typed unsupported result; it never tries the local family implementation. A provider may -implement a complete runtime directly, as Cloud WebDriver can, or contribute a narrow transport used -inside the owning family module, as an Android transport can. Provider callback stacks threaded -through daemon handlers are not part of the new seam. +### 3. Provider ownership is exact and fail-closed -Ordinary binding performs the arbitration above. Every `DeviceBinding` also exposes a unique, -restart-stable runtime-owner reference identifying the local family owner or the configured provider -runtime instance, not merely a provider kind. Composition rejects duplicate owner references. Durable -descriptors embed that reference; exact-owner binding resolves it directly, validates descriptor -device/fence identity, and never reruns ordinary ownership arbitration or `ownsDevice`. An unavailable -expected provider is `unreattachable: owner-unavailable`; it never becomes a local bind. +Provider resolution for one device has three outcomes: + +1. If exactly one provider owns it, bind that provider-owned runtime. +2. If no provider owns it, bind the local family runtime. +3. If multiple providers own it, fail with an ownership-contract error. + +Only providers that declare device-runtime ownership participate in this arbitration. +A narrow tool, transport, app-log, or recording provider is an injected dependency of the selected runtime. +It does not become a second runtime owner or participate in the owner count. + +Provider ownership is authoritative. +If the owning provider lacks a required fact or facet, admission returns the typed unsupported result. +It never tries the local family implementation. +A provider may implement a complete runtime directly, as Cloud WebDriver can. +It may instead contribute narrow transport inside the owning family module, as an Android transport can. +Provider callback stacks through daemon handlers are not part of the new seam. + +Ordinary binding uses the arbitration above. +Each `DeviceBinding` also exposes a unique, restart-stable runtime-owner reference. +It identifies the local family owner or configured provider runtime instance, not merely the provider kind. +Composition rejects duplicate owner references. +Durable descriptors contain this reference. +Exact-owner binding resolves it directly and validates descriptor device/fence identity. +It never repeats ordinary arbitration or `ownsDevice`. +An unavailable expected provider returns `unreattachable: owner-unavailable`. +It never becomes a local bind. ### 4. Bindings attach to process-lived helper managers; they do not own helpers -Platform gateways and their helper managers are process-lived. Individual helper generations remain -lazy, identity-bound, invalidatable, restartable, and idle-stoppable under ADR 0002. A request binding -attaches cancellation, diagnostics, progress, and admitted device/session context. Disposing it -releases only those request attachments and request-local acquisitions. - -The underlying `DeviceBinding` implements `AsyncDisposable` and remains private to -`RequestExecutionScope`; handlers receive only a non-disposable `BoundDeviceRuntime` projection. -Each gateway bind owns a private rollback stack and publishes a binding only after every request-local -acquisition succeeds. Failure after any internal acquisition releases that partial state before the -bind rejects. On publication, ownership transfers exactly once to a repository-owned, reverse-order -scope disposal stack; caching another projection never registers or disposes the binding twice. - -Request execution invokes that stack from `finally`. Source-executed TypeScript does not use -`await using` while the supported minimum Node 22 runtime cannot parse that syntax, type stripping -cannot lower it, and Node 22 has no `AsyncDisposableStack`; the contract keeps a later syntax migration -mechanical. A binding itself is never adopted beyond the request; only a durable resource handle -returned by one of its facets may transfer to session ownership. - -Persistent helper shutdown happens only through explicit device lifecycle policy, helper-manager -invalidation, platform idle policy, or platform-module shutdown after daemon-session resources have -been finalized. After adoption, a durable handle may retain its own helper attachment beyond the -originating request; that attachment belongs to the handle and is no longer registered for -request-scope disposal. Adoption detaches every originating request diagnostic/event/progress port. -Later `finish` or forced disposal executes inside the current request observability scope or an -explicit daemon session-teardown observability scope; a durable handle never retains an old request -async context. - -A shared lifecycle contract scenario is required for every helper-backed runtime. With idle expiry -disabled and no invalidation, process exit, identity change, or ownership transfer, it proves that -disposing request bindings never terminates or restarts the healthy helper and an immediate later -binding reuses that generation. Module shutdown terminates each still-live, still-owned generation at -most once. Every durable-resource facet separately proves that its adopted resource survives -originating-binding disposal. Each platform supplies honest evidence appropriate to its helper rather -than relying on wall-clock behavior alone. Bind contract scenarios also fail after each successive -internal acquisition and prove that the unpublished partial binding leaves no request attachment. - -Published bindings are disposed on success, failure, cancellation, and early return, in reverse -acquisition order; unpublished partial binds roll themselves back before rejecting. Disposal runs -inside the active request diagnostics/event context before `request.finished`, diagnostics flush, -final response construction, and device/session lock release, so cleanup-only failure cannot follow a -recorded success. If the operation and cleanup both fail, the operation error remains primary and -cleanup is structured secondary diagnostic evidence; a cleanup-only failure surfaces normally. +Platform gateways and helper managers live for the process lifetime. +Under ADR 0002, helper generations remain lazy, identity-bound, invalidatable, restartable, and idle-stoppable. +A request binding attaches cancellation, diagnostics, progress, and admitted device/session context. +Its disposal releases only request attachments and request-local acquisitions. + +The underlying `DeviceBinding` implements `AsyncDisposable` and remains private to `RequestExecutionScope`. +Handlers receive only a non-disposable `BoundDeviceRuntime` projection. +Each gateway bind owns a private rollback stack. +It publishes a binding only after all request-local acquisitions succeed. +If any step fails after an internal acquisition, the bind releases the partial state before it rejects. +Publication transfers ownership exactly once to a repository-owned scope disposal stack that operates in reverse order. +Caching another projection never registers or disposes the binding twice. + +Request execution calls the stack from `finally`. +While the minimum supported Node 22 runtime cannot parse `await using`, type stripping cannot lower it, and Node 22 lacks `AsyncDisposableStack`, source-executed TypeScript does not use `await using`. +The contract keeps a later syntax migration mechanical. +A binding is never adopted beyond the request. +Only a durable resource handle that one of the binding's facets returns may transfer to session ownership. + +Persistent helper shutdown occurs only through these paths: + +- Explicit device lifecycle policy. +- Helper-manager invalidation. +- Platform idle policy. +- Platform-module shutdown after daemon-session resources are finalized. + +After adoption, a durable handle may retain its own helper attachment beyond the original request. +This attachment belongs to the handle and is no longer registered for request-scope disposal. +Adoption detaches every original request diagnostic/event/progress port. +Later `finish` or forced disposal uses the current request observability scope or an explicit daemon session-teardown observability scope. +A durable handle never retains an old request async context. + +Every helper-backed runtime requires a shared lifecycle contract scenario. +The scenario disables idle expiry and excludes invalidation, process exit, identity change, and ownership transfer. +It proves that request-binding disposal never terminates or restarts the healthy helper. +It also proves that an immediate later binding reuses that generation. +Module shutdown terminates each live, still-owned generation at most once. +Each durable-resource facet separately proves that its adopted resource survives original-binding disposal. +Each platform supplies honest evidence appropriate to its helper. Wall-clock behavior alone is not sufficient. +Bind contract scenarios also fail after each successive internal acquisition. +They prove that an unpublished partial binding leaves no request attachment. + +Published bindings are disposed in reverse acquisition order on success, failure, cancellation, and early return. +Unpublished partial binds roll back before rejection. +Disposal occurs inside the active request diagnostics/event context, before these events: + +- `request.finished`. +- Diagnostics flush. +- Final response construction. +- Device/session lock release. + +Thus, cleanup-only failure cannot follow recorded success. +If operation and cleanup both fail, the operation error remains primary. +Cleanup becomes structured secondary diagnostic evidence. +A cleanup-only failure surfaces normally. ### 5. Durable resources are reattachable by the same owner -> **Proposed amendment (ADR 0024, 2026-09-14), `screen-recording` only.** Screen recording is a -> *stop-and-collect* resource: its artifact is a file the native recorder finalizes on signal, and -> its identity can only be re-proved at signal time. It keeps the persisted manifest, the -> ownership fence on every destructive step, and the completed replay. It replaces the `reattach` -> and `cleanup` facet operations with the backend's `stop(target, budget)` and `collect`, drops -> `cleanup-pending` as a phase and the admission ledger for this kind, records the recorder -> observation and the native-path disposition beside the committed export, and archives a -> manifest by fence generation until both are settled. -> For every durable kind, forced cleanup is no longer inferred from a failed finish once that -> kind's failed-finish test states what its retry needs. App-log, audio-probe, and perf-capture -> keep the contract below unchanged. Rationale, matrix, and tests live in ADR 0024. - -App-log streams, screen recordings, and native profiler captures may outlive one request. Starting -durable work returns: - -- a **live resource handle**, which is process-local authority to finish or forcibly dispose the - active resource; and -- a **durable resource descriptor**, which is bounded, JSON-safe, versioned state sufficient for the - exact runtime owner to make a deterministic recovery attempt. - -Facet-specific handles expose `finish(): Promise` for idempotent normal completion, -`forceCleanup(): Promise` for outcome-bearing forced cleanup, and idempotent -`[Symbol.asyncDispose](): Promise` as the scope-cleanup adapter. The adapter resolves only for -confirmed cleanup/already-missing outcomes and rejects with a normalized ownership-lost or -cleanup-pending error otherwise; it does not pretend `Promise` carries a typed result. - -Every destructive finish/cleanup operation holds the authoritative ownership lease from fence -validation through the native side effect and persisted lifecycle transition, or passes a fencing -token to a native/provider operation that atomically rejects stale owners. A check immediately before -the side effect is not sufficient. A transport that can provide neither mechanism cannot claim -transferable reattachment; cleanup-only recovery requires proof that the prior owner is gone, and -otherwise remains manual recovery. - -A facet start internally rolls back if it cannot publish a complete handle/descriptor pair, and the -start promise is invoked through the request scope's pending-transfer guard. The scope registers the -guarded handle for forced cleanup before resolving the result to command orchestration, then releases -that guard only after the authoritative active record and the live `SessionState` handle/descriptor -adoption both succeed. Cancellation or failure anywhere in that gap disposes the unadopted handle and -terminalizes the record only after confirmed cleanup; uncertain cleanup leaves the record -cleanup-pending. Contract scenarios inject cancellation and failure between partial start, -publication, persistence, live adoption, and transfer. - -There is no generic `PlatformResource`. Live `SessionState` may retain a neutral facet-specific -contract handle beside its descriptor and metadata; the field has one R7 transition owner. Only the -descriptor and neutral metadata enter the authoritative persisted recovery record. Concrete platform -classes, provider clients, child handles, timers, transports, and wait promises enter neither store. - -The daemon shares the lifecycle mechanics of those facet-specific resources through one -`DurableCaptureResource` coordinator. It owns bounded manifest I/O, per-resource fence serialization, -start/persist/adopt compensation, terminal transitions, and deadline-bounded exact-owner recovery. -The coordinator receives a facet's resource kind and manifest store, neutral session slot, completion -metadata projection, failure wording, and exact-owner recovery adapter; it does not select platforms, -interpret command flags, or form a generic runtime facet. App-log and screen recording retain distinct handles, -descriptors, facts, native finalization semantics, and admission policy. Reusable codec/live-handle -mechanics below the daemon remain in `@agent-device/capture-kit`, preserving the package direction. - -A daemon-owned, process-lifetime admission ledger may retain bounded cleanup uncertainty that has no -honest durable representation, but it is not a second live-resource store: it contains no handle or -descriptor, never supersedes the persisted manifest, and is keyed by canonical device identity when -that identity is known. Evidence that can be checked again, such as a retained legacy marker path, is -revalidated at admission so manual recovery can unblock the matching device without a daemon restart. -Unknown-identity evidence remains globally fail-closed, while undurable in-memory blocks expire only -under an explicit bounded policy with diagnostics. - -Persisted JSON re-enters as `unknown`. Contracts first validate a neutral envelope containing resource -kind/envelope version, session/device identity, exact owner reference, fence, and lifecycle state. -Only after exact-owner selection does that facet's total codec decode its descriptor body. Invalid or -unknown envelopes/bodies remain retained recovery evidence with a typed -`descriptor-invalid`/`descriptor-version-unsupported` outcome; they are never cast to a descriptor, -deleted, or offered to another facet. - -When no live handle is available, the daemon completes lease/admission and takeover fencing, binds -the descriptor's exact owner, and calls that facet's typed `reattach`. Reattachment returns a closed -outcome: `active` with a handle, `completed` with its result, `missing`, or `unreattachable` with a -typed reason. Unsupported descriptor schema/version returns -`unreattachable: descriptor-version-unsupported`: the facet does not guess a migration, discard the -descriptor, fall through to another owner, or start a replacement. Other closed reasons include -`owner-unavailable`, `transport-not-reattachable`, and `ownership-fence-lost`. A descriptor may -explicitly declare cleanup-only recovery when its native transport cannot reconstruct equivalent live -control; that limitation is a runtime fact, not a best-effort guess during takeover. - -Reattachment does not rehydrate `SessionState` or change `SESSION_NOT_FOUND` policy. With no admitted -live session, a reconstructed handle is recovery-scope-owned and may only complete or clean up the -resource; adopting it for continued session use requires an independently admitted live session. - -Every durable facet also exposes a typed, idempotent -`cleanup(descriptor, fence, scope): Promise` for descriptor-only forced cleanup. -Cleanup-only recovery uses this operation instead of pretending to reconstruct a full handle, and its -closed outcome records cleaned, already missing, or still cleanup-pending state. Finish, dispose, and -descriptor cleanup all use the same serialized/atomically enforced fence. If the exact owner or -descriptor schema is unavailable, cleanup cannot be guessed: the authoritative record remains -cleanup-pending with a manual-recovery reason. - -The descriptor must have an authoritative persisted home before command start success is returned. -Prefer the platform/resource-owned recovery manifest when that resource already owns one. If a -resource has no honest manifest, introduce a daemon-owned persisted resource record only for that -demonstrated need, and extract a shared resource index only after multiple resource types earn it. -Current diagnostics/session events—and any future journal from proposed ADR 0018—are -observability-only and never a recovery source of truth. They may mirror descriptor lifecycle events, -but reattachment never scans telemetry to rebuild state. - -Every authoritative home exposes a deterministic facet-owned lookup or enumeration path after -process loss. Its neutral record carries session/device identity, the exact runtime-owner reference, -descriptor and metadata, an ownership/fence token, and one of two persisted lifecycle states: -`open` or `completed`. In-progress distinctions such as starting, active, completing, and -cleanup-pending are phase metadata on the open record, not additional lifecycle states. The fence and -the descriptor remain authoritative across every open phase; cleanup uncertainty therefore cannot be -encoded as a terminal lifecycle. A new handle is not exposed until the persisted ownership fence is -acquired. Every finish/cleanup attempt holds that ownership guard through destructive work and the -persisted transition, or delegates to an operation that atomically enforces the token, so a prior -owner cannot later terminate a transferred resource. - -Persisting a descriptor does not make external-resource start and descriptor write atomic. A -platform whose native tool cannot close that crash window retains a platform-owned orphan marker or -equivalent recovery protocol. Descriptors contain stable reconstruction coordinates, not promises, -timers, child handles, buffers, credentials, or a dump of live implementation state. - -Ownership transfer is explicit. Start persists active recovery evidence before returning command -success, and the pending guard transfers only after live session adoption. Completion preserves a -terminal result or stable result coordinates before active recovery evidence is removed. Once -adopted, request-binding disposal cannot stop the resource. Normal session teardown preserves -facet-specific ordering; adopted durable handles are not placed in the request binding's generic -disposable transaction. +> **Proposed amendment (ADR 0024, 2026-09-14), `screen-recording` only.** +> Screen recording would be a *stop-and-collect* resource. +> Its artifact is a file that the native recorder finalizes on signal. +> Only signal time permits renewed proof of recorder identity. +> It would retain the persisted manifest, ownership fences on every destructive step, and completed replay. +> Backend `stop(target, budget)` and `collect` would replace the `reattach` and `cleanup` facet operations. +> This kind would remove the `cleanup-pending` phase and admission ledger. +> It would record the recorder observation and native-path disposition beside the committed export. +> A manifest would remain archived by fence generation until both are settled. +> For each durable kind, failed finish would no longer imply forced cleanup once its failed-finish test states its retry requirements. +> App-log, audio-probe, and perf-capture would retain the contract below unchanged. +> ADR 0024 contains the rationale, matrix, and tests. + +App-log streams, screen recordings, and native profiler captures may outlive one request. +A durable start returns two values: + +- A **live resource handle** supplies process-local authority to finish or forcibly dispose the active resource. +- A **durable resource descriptor** supplies bounded, JSON-safe, versioned state that is sufficient for a deterministic recovery attempt by the exact runtime owner. + +Facet-specific handles expose these operations: + +- `finish(): Promise` for idempotent normal completion. +- `forceCleanup(): Promise` for forced cleanup with an outcome. +- Idempotent `[Symbol.asyncDispose](): Promise` as the scope-cleanup adapter. + +The adapter resolves only for confirmed cleanup or already-missing outcomes. +Otherwise, it rejects with a normalized ownership-lost or cleanup-pending error. +`Promise` does not carry a typed result. + +Each destructive finish/cleanup operation holds the authoritative ownership lease across fence validation, native side effect, and persisted lifecycle transition. +Alternatively, it passes a fencing token to a native/provider operation that atomically rejects stale owners. +A check immediately before the side effect is insufficient. +A transport without either mechanism cannot claim transferable reattachment. +Cleanup-only recovery then requires proof that the prior owner is gone. +Otherwise, recovery remains manual. + +If a facet start cannot publish a complete handle/descriptor pair, it rolls back internally. +The request scope invokes the start promise through its pending-transfer guard. +Before command orchestration receives the result, the scope registers the guarded handle for forced cleanup. +The scope releases that guard only after the authoritative active record and live `SessionState` handle/descriptor adoption both succeed. +Cancellation or failure in this interval disposes the unadopted handle. +The record becomes terminal only after confirmed cleanup. +Uncertain cleanup leaves the record cleanup-pending. +Contract scenarios inject cancellation and failure between partial start, publication, persistence, live adoption, and transfer. + +There is no generic `PlatformResource`. +Live `SessionState` may retain a neutral facet-specific contract handle beside its descriptor and metadata. +One R7 transition owner owns the field. +Only the descriptor and neutral metadata enter the authoritative persisted recovery record. +Neither store contains these values: + +- Concrete platform classes. +- Provider clients. +- Child handles. +- Timers. +- Transports. +- Wait promises. + +One daemon `DurableCaptureResource` coordinator shares lifecycle mechanics across facet-specific resources. +It owns bounded manifest I/O, per-resource fence serialization, start/persist/adopt compensation, terminal transitions, and deadline-bounded exact-owner recovery. +It receives these facet-specific inputs: + +- Resource kind and manifest store. +- Neutral session slot. +- Completion metadata projection. +- Failure wording. +- Exact-owner recovery adapter. + +It does not select platforms or interpret command flags. +It does not form a generic runtime facet. +App-log and screen recording retain separate handles, descriptors, facts, native finalization semantics, and admission policy. +Reusable codec/live-handle mechanics below the daemon remain in `@agent-device/capture-kit` to preserve package direction. + +A daemon-owned admission ledger with process lifetime may retain bounded cleanup uncertainty that has no honest durable representation. +The ledger is not a second live-resource store. +It contains no handle or descriptor and never supersedes the persisted manifest. +When canonical device identity is known, that identity keys the ledger. +Admission revalidates evidence that permits another check, such as a retained legacy marker path. +Thus, manual recovery can unblock the matching device without a daemon restart. +Unknown-identity evidence remains globally fail-closed. +In-memory blocks without durable evidence expire only under an explicit bounded policy with diagnostics. + +Persisted JSON re-enters as `unknown`. +Contracts first validate a neutral envelope with these fields: + +- Resource kind/envelope version. +- Session/device identity. +- Exact owner reference. +- Fence. +- Lifecycle state. + +Only after exact-owner selection does the facet's total codec decode the descriptor body. +Invalid or unknown envelopes/bodies remain retained recovery evidence with typed `descriptor-invalid`/`descriptor-version-unsupported` outcomes. +They are never cast to a descriptor, deleted, or offered to another facet. + +If no live handle is available, the daemon completes lease/admission and takeover fences. +It then binds the descriptor's exact owner and calls the facet's typed `reattach`. +Reattachment returns one closed outcome: + +- `active` with a handle. +- `completed` with its result. +- `missing`. +- `unreattachable` with a typed reason. + +Unsupported schema/version returns `unreattachable: descriptor-version-unsupported`. +The facet does not guess a migration, discard the descriptor, try another owner, or start a replacement. +Other closed reasons include `owner-unavailable`, `transport-not-reattachable`, and `ownership-fence-lost`. +If native transport cannot reconstruct equivalent live control, a descriptor may explicitly declare cleanup-only recovery. +This limit is a runtime fact, not an assumption during takeover. + +Reattachment does not reconstruct `SessionState` or change `SESSION_NOT_FOUND` policy. +Without an admitted live session, the recovery scope owns the reconstructed handle. +It may only complete or clean up the resource. +Continued session use requires an independently admitted live session. + +Each durable facet also exposes typed, idempotent `cleanup(descriptor, fence, scope): Promise` for descriptor-only forced cleanup. +Cleanup-only recovery uses this operation without reconstructing a full handle. +Its closed outcome records cleaned, already missing, or still cleanup-pending state. +Finish, dispose, and descriptor cleanup share the serialized or atomically enforced fence. +If the exact owner or schema is unavailable, cleanup cannot be inferred. +The authoritative record remains cleanup-pending with a manual-recovery reason. + +Before command start success returns, the descriptor must have an authoritative persisted home. +Prefer an existing platform/resource-owned recovery manifest. +If a resource has no honest manifest, introduce a daemon-owned persisted resource record only for that demonstrated need. +Extract a shared resource index only after multiple resource types justify it. +Current diagnostics/session events and a future journal from proposed ADR 0018 serve observability only. +They never become recovery authority. +They may mirror descriptor lifecycle events. +Reattachment never scans telemetry to reconstruct state. + +Each authoritative home exposes deterministic facet-owned lookup or enumeration after process loss. +Its neutral record contains these values: + +- Session/device identity. +- Exact runtime-owner reference. +- Descriptor and metadata. +- Ownership/fence token. +- Persisted lifecycle state: `open` or `completed`. + +In-progress distinctions, for example starting, active, completing, and cleanup-pending, are phase metadata on the open record, not additional lifecycle states. +The fence and descriptor remain authoritative across all open phases. +Therefore, cleanup uncertainty cannot be encoded as a terminal lifecycle. +A new handle remains private until acquisition of the persisted ownership fence. +Each finish/cleanup attempt holds the guard across destructive work and the persisted transition. +Alternatively, the attempt delegates to an operation that atomically enforces the token. +These mechanisms prevent a previous owner from terminating a transferred resource. + +Descriptor persistence does not make external-resource start and descriptor write atomic. +If a native tool cannot close this crash interval, the platform retains a platform-owned orphan marker or equivalent recovery protocol. +Descriptors contain stable reconstruction coordinates. +They contain no promises, timers, child handles, buffers, credentials, or dump of live implementation state. + +Ownership transfer is explicit. +Start persists active recovery evidence before command success. +The pending guard transfers only after live session adoption. +Completion preserves a terminal result or stable result coordinates before removal of active recovery evidence. +After adoption, request-binding disposal cannot stop the resource. +Normal session teardown preserves facet-specific order. +Adopted handles do not enter the request binding's generic disposable transaction. ### 6. Command execution has one closed shape -The command descriptor declares command identity and one internal-only execution mode excluded from -public projections: `none`, `host`, device `runtimeUse`, or `inventoryUse`. A `none` descriptor -never binds a device, carries capability admission, owns a platform adapter, or declares a runtime -or inventory use. The mode is explicit on every descriptor; there is no registry default. ADR -0003's daemon-owned route table continues to own handler and request-policy traits independently. - -During adoption, one command descriptor across its complete platform/provider denominator was the -abandonment-safe migration unit. A parametrized cutover table proved that each unit deleted its old -adapter, admission and dispatch projection before merge. Those rows were explicitly temporary and -were deleted after the final unit completed. The permanent state is smaller: the execution union -cannot express `legacy`, descriptors cannot carry capability admission, R66 rejects manufactured -runtime proof or restored legacy admission, and R65 rejects every daemon dependency on a concrete -platform implementation. - -A behavior-neutral package/registry substrate may land before the first command only when it routes -no production descriptor, is dead-code clean, independently revertible, and preserves lazy loading. -Otherwise it lands with the `devices` command unit. Shared Apple-runner or Android-helper mechanics -used by migrated and legacy commands are not duplicated or connected through a package-to-root -back-import. They either remain physically in place until their last consumer can move, or move -behind a neutral lower-level capability injected by composition into both the package facet and the -complete legacy adapter while command execution ownership remains singular. - -The substrate and every command unit are reviewed from a clean committed tree with all production -files present in `HEAD`; a green layering run while new production files are untracked is not -evidence. Platform packages receive no migration allowlist or root back-import exception. -`check:affected` must select platform contract, provider, and coverage scenarios for a -`packages/platform-*` change. Every new structural gate is accepted only after a planted violation -demonstrates the intended red result. - -Any unit touching `SessionState` classifies each changed field once under R7, keeps request bindings, -concrete platform/provider types, and raw process/transport mechanics out, and gives each mutable -resource field one transition owner. The permitted live value is the opaque implementation behind a -neutral facet-specific handle contract. The unit proves partial-start cleanup, transfer, finish, -forced disposal, and error precedence. Equality-pinned R10 writer/owner counts and external -`daemon/types.ts` importer membership are lowered in the same unit when they shrink. R9 total-cycle -and R10 cycle-zone pressure may not grow, and R9 is equality-pinned since #1781 A6: a unit that -shrinks the cycle lowers the zone ceilings in the same change rather than leaving the slack open. +Each descriptor declares command identity and one internal-only execution mode, excluded from public projections: + +- `none` +- `host` +- Device `runtimeUse` +- `inventoryUse` + +A `none` descriptor never binds a device or carries capability admission. +It owns no platform adapter and declares no runtime or inventory use. +Each descriptor explicitly declares its mode. The registry has no default. +ADR 0003's daemon-owned route table independently owns handler and request-policy traits. + +During adoption, the abandonment-safe migration unit was one command descriptor across its complete platform/provider denominator. +A temporary parametrized cutover table proved that each unit deleted its old adapter, admission, and dispatch projections before merge. +Those rows were deleted after the final unit completed. +The permanent union cannot express `legacy`. +Descriptors cannot carry capability admission. +R66 rejects manufactured runtime proof or restored legacy admission. +R65 rejects every daemon dependency on a concrete platform implementation. + +Migrated and legacy commands do not duplicate shared Apple-runner or Android-helper mechanics. +They do not connect to these mechanics through a package-to-root back-import. +Either these mechanics remain physically in place until their last consumer can move, or they move behind a neutral lower-level capability. +Composition injects that capability into both the package facet and the complete legacy adapter. +Command execution ownership remains singular. + +Review each package/registry change and each command unit from a clean committed tree with all production files present in `HEAD`. +A passing layering check with untracked production files is not evidence. +Platform packages receive no migration allowlist or root back-import exception. +Temporary fixtures never authorize a production bridge, a duplicate route, or a recorded package back-import. +For a `packages/platform-*` change, `check:affected` must select platform contract, provider, and coverage scenarios. +A new structural gate requires a planted violation that proves the intended failure before acceptance. + +Any unit that touches `SessionState` classifies each changed field once under R7. +It excludes request bindings, concrete platform/provider types, and raw process/transport mechanics. +Each mutable resource field has one transition owner. +The only permitted live value is the opaque implementation behind a neutral facet-specific handle contract. +The unit proves partial-start cleanup, transfer, finish, forced disposal, and error precedence. +If equality-pinned R10 writer/owner counts shrink, lower them in the same unit. +If external `daemon/types.ts` importer membership shrinks, lower it in the same unit. +R9 total-cycle pressure and R10 cycle-zone pressure may not grow. +R9 is equality-pinned since #1781 A6. +If a unit shrinks the cycle, it lowers zone ceilings in the same change without unused allowance. ### 7. Adoption checkpoint (historical) -No command migration beyond the metadata/package substrate and complete `devices`, `logs`, and -`network` command cutovers began until this ADR's Status recorded **continue**. `network` was included -because it consumes the app-log resource. Daemon-owned generic session teardown may invoke the -neutral app-log handle's forced-disposal contract without changing `close`'s legacy platform adapter; -the `logs` unit proves that path through `close`. If `close`, `open`, or another command instead must -bind a runtime or select app-log platform behavior, its full unit must be named in Status before the -substrate lands. `logs` is the deliberate first descriptor/reattachment pilot, not merely a -request-lifetime handle exercise. - -Before the substrate lands, the tracking issue records and reviews the exact commands, thresholds, -scenario denominator, and scoped counts used below. The same measures are reported from the clean -committed checkpoint tree. - -The checkpoint records exactly one outcome: - -- **continue**: every hard evidence item passes and broader command migration may proceed; -- **revise**: amend the contracts/ADR, rerun the checkpoint, and begin no further command migration; or -- **stop**: abandon broader migration; already-landed command units remain coherent or are retired by - a separate explicit decision. - -Any failed hard item requires **revise** or **stop**; recording evidence of the failure is not -evidence for **continue**. - -Evidence must cover: - -- one platform-execution path, an unchanged ADR 0003 route/policy projection, and zero old capability - closures, tags/maps, or concrete daemon platform imports for each migrated command; -- package direction and the absence of daemon request/session/command types in platform packages; -- independent package builds/declarations, metadata-eager/implementation-lazy loading, and reviewed - startup/build/package thresholds; -- typed runtime-use narrowing and a planted-red narrowing-gate violation; -- six-family runtime ownership, six local inventory sources, provider inventory sources, and - independently enumerated leaf/device/provider support parity and scenario counts; -- provider-first fail-closed behavior plus unique restart-stable exact-owner references; -- rollback after every partial bind acquisition and published-binding disposal without - persistent-helper shutdown; -- descriptor persistence and exact-owner recovery after loss of a process-local handle, including - validated decoding, resumed control for reattachable cells, executable descriptor-only cleanup, - and explicit cleanup-only/version-unreattachable behavior; -- pending-handle cleanup across every start/persist/adopt/transfer gap, durable-handle transfer, - serialized or native-atomic fencing, idempotent teardown, and operation/cleanup error precedence; -- semantic/provider/device parity for `devices`, `logs`, and `network`; -- unchanged-or-lower R7/R10, type-cycle, and external-daemon-type ratchets; and -- numeric before/after daemon platform import, branch, and tag counts with a strict net decrease. - -The seam is revised or stopped before broader migration if a platform package needs root/daemon or -sibling-platform imports; a command needs old/new or provider/local execution fallback; runtime -facets degenerate into tags, command switches, or opaque execution payloads; leaf/provider facts -require default fallthrough; `SessionState` names/exposes concrete platform/provider types or raw -mechanics rather than the permitted neutral live handle; a persisted record bypasses the validated -neutral envelope or facet-owned descriptor codec, or contains raw live mechanics; implementation -laziness cannot be preserved; R7/R10/type-cycle pressure grows; or the landed slices add more daemon -platform ownership than they remove. - -#### Checkpoint result: continue under the revised cumulative budget (2026-08-10) - -The final checkpoint compares `44c298d7f3a0ef84bc47f34c54d88b6c9eeb0df2` with stack head -`457fafe6399a95a4ddbfac57f02b3a7fe4157a54` on the same host and toolchain. Merged `devices` -`c06bed9f773a27ae0a02cb012570def2f2d0b90e` is part of that range; rebasing the remaining PRs onto it -does not reset the denominator. The earlier `99f5af1b7` checkpoint missed a stale owned-marker wedge, -Apple scoped-provider bypass, Android optional-recovery loss, and absolute network line-number -regression. The later cleanup review found a cross-device retained-marker wedge, a manual-recovery -hint that remained blocked until daemon restart, and an Android app-log stream that stayed pinned to -the pre-relaunch app PID. Each correction was observed red before its focused test passed; the final -admission test proves removal of the matching retained marker permits a second start in the same -daemon process. On exact final logs head `188795386`, a controlled Android relaunch moved the app from -PID 10952 to 11455, rotated the durable logcat marker to PID 11455, and retained both before/after -canaries plus the new process output in one `app.log`. Guarded daemon/dead-child recovery separately -terminalized the manifest and allowed a clean second start. - -The required revision package is complete: - -- `@agent-device/capture-kit` is the private durable-capture implementation package. Contracts keeps - pure runtime types and plan models; capture-kit owns process/recovery/live-handle mechanics, - envelope/descriptor codecs, and network parsers. The dependency order is - `kernel < contracts < capture-kit < platform/provider/daemon`. A planted-red gate rejects process, - filesystem, or timer mechanics drifting into contracts. The package is wired into affected-check - selection, the layering test enumeration, and the workspace typecheck project list. -- Canonical kernel device identity replaces the duplicate encoders while facts-shape validation stays - separate. The bounded Android/Harmony PID owner factory lives in capture-kit; Apple and Limrun stay - custom, there is no network factory, and all loaders retain their lazy boundary. The managed-command - allowlist, unused façade exports, public Limrun runtime-module declaration leak, quadratic JSON walk, - and duplicate lifecycle/reconnect mechanics are gone. The temporary Android implementation files - introduced by the devices PR are also absent from contracts at this checkpoint. -- Plan-declared app-session requirements replace repeated handler guards. Persisted durable lifecycle - is `open | completed`, with transient phase retained as metadata. A daemon-owned admission ledger - scopes decodable evidence by device identity, rechecks manually removed legacy markers, bounds - undurable blocks, and remains subordinate to the durable manifest. Discriminated teardown settles - app-log state, re-reads the replaced session record, then runs generic teardown, avoiding the stale - reference and double-cleanup path. - -The final gates passed: - -- `pnpm check:affected --run` at `457fafe63` passed 799 test files / 6,424 tests with 2,097/2,401 - changed executable lines covered (87.34%), plus format, lint, typecheck, build/declarations, - published-package clean install, fallow, provider integration, replay compatibility, and - integration-progress checks. -- `pnpm check:layering` passed 131 structural/model tests and scanned 1,157 production source files. - R11 owns 17 workspace packages behind 39 exported subpaths with no root back-imports; R13 keeps six - private implementation-lazy platform packages above capture-kit behind one canonical composition - root and its single private provider-composition implementation submodule; the historical R14 - and R15 rules retained one typed route for `logs` and `network` with no legacy route. -- Six local inventory/runtime owners, all enumerated Apple leaf/kind cells, and the production - BrowserStack, AWS Device Farm, and Limrun provider modes remain covered. Provider ownership and - inventory are fail-closed; exact-owner recovery and provider-authoritative tests prove there is no - provider-to-local fallback. Durable tests cover every start/persist/adopt gap, exact-owner recovery, - cleanup-only and descriptor-only cleanup, fencing, idempotence, and primary-error precedence. -- R7/R10 remain at 23 writer-owned `SessionState` fields and 29 owner claims, with all 34 fields - classified. The largest type cycle is 46 against the 47-file ceiling, and the two external - production `daemon/types.ts` importers are unchanged. Checkpoint-owned `logs`/`network` platform - decision lines remain zero. - -Issue 1704 revised the budget for this checkpoint for three explicit reasons: capture-pipeline -reliability through fenced ownership and exact recovery, durable cloud log streaming that did not -exist in the baseline, and a reusable durable-capture substrate. This is not a new observability -layer: the CLI-observable `logs` behavior remains parity work. The budget stays cumulative from -`44c298d7f`; the exact clean post-revision measurement is the reviewed upper bound for this checkpoint, -not a reusable allowance for future units: - -| Metric | Original baseline | Revised cumulative bound / checkpoint | Change | -| ------------------ | ----------------: | ------------------------------------: | ------------------: | -| Raw JavaScript | 2,036,067 B | 2,131,689 B | +95,622 B (+4.696%) | -| Gzipped JavaScript | 659,646 B | 695,134 B | +35,488 B (+5.380%) | -| npm tarball | 797,027 B | 826,761 B | +29,734 B (+3.731%) | -| npm unpacked | 2,781,186 B | 2,878,492 B | +97,306 B (+3.499%) | - -The controlled 15-run startup medians showed no regression (`--version` 94.5 ms to 47.4 ms; -`--help` 91.2 ms to 72.0 ms). Module-level source-map inspection found no duplicate implementation -emission; the distribution cost is the accepted reliability/cloud/substrate decision above. Future -units must define and review their own cumulative budget rather than inheriting this headroom. - -The recordings command unit has its own reviewed budget (2026-08-11). The cumulative denominator -remains the original `44c298d7f` baseline; rebasing onto the completed checkpoint does not reset it. -The immediate stack-base delta from durable-capture head `e3b0956b` is reported separately so the -cost of this command unit stays visible. The exact #1724 head that reproduces this table is recorded -in the acceptance comment before readiness. The increase pays for runtime-owned screen-recording -transports, fenced artifact finalization and cross-daemon recovery, and provider parity. It is not -unused checkpoint headroom and is not an allowance for a later command: - -| Metric | Original baseline | Recording bound | Cumulative change | Recording-only change | -| ------------------ | ----------------: | --------------: | -------------------: | --------------------: | -| Raw JavaScript | 2,036,067 B | 2,166,159 B | +130,092 B (+6.389%) | +32,026 B (+1.501%) | -| Gzipped JavaScript | 659,646 B | 708,776 B | +49,130 B (+7.448%) | +12,902 B (+1.854%) | -| npm tarball | 797,027 B | 836,426 B | +39,399 B (+4.943%) | +8,987 B (+1.086%) | -| npm unpacked | 2,781,186 B | 2,913,430 B | +132,244 B (+4.755%) | +32,494 B (+1.128%) | - -These bounds admit only the completed recordings cutover. Every subsequent command unit must define -and review both its original-baseline cumulative bound and its immediate stack-base delta. - -The tracking issue owns command order, PR/file lists, test-only compatibility fixtures, exact -benchmark commands and thresholds, raw evidence, and reviewers. Temporary fixtures never authorize -a production bridge, duplicate route, or recorded package back-import. After the checkpoint, this -ADR's status records its outcome; it does not retain a migration diary. +Git history retains the completed checkpoint procedures and results. ### 8. Broader migration was move-dominated and evidence-tiered -The checkpoint and recordings budgets paid for machinery that later units reuse; they are not a -precedent for growth. Every post-checkpoint unit still defines its original-baseline cumulative -bound and immediate stack-base delta, but the default expectation inverts: a unit relocates -existing platform mechanics from root `src/` into platform packages and deletes superseded daemon -code, so its shipped-size delta trends to zero or negative. The unit's review reports root bytes -removed against package bytes added; net growth is exceptional and each contributing addition is -named and justified individually. Contract modules stay vocabulary-thin under the existing -capture-kit rule; per-unit type inflation is size growth and is reviewed as such. - -#### W5 Android family accounting (PR #2117) - -The W5 exact-head size report at `9fa2778` against `437465f37` reported these rounded CI values: - -| Metric | Original baseline | W5 head | W5 delta | -| ------------------ | ----------------: | ------: | -------: | -| Raw JavaScript | 2.48 MB | 2.49 MB | +8.2 kB | -| Gzipped JavaScript | 835.0 kB | 831.2 kB | -3.9 kB | -| npm tarball | 958.5 kB | 959.6 kB | +1.1 kB | -| npm unpacked | 3.32 MB | 3.33 MB | +9.0 kB | - -At source level, the moved Android production tree accounts for 456,492 B removed from -`src/platforms/android` versus 475,413 B in changed package targets, a +18,921 B relocation -delta; the new root host/facade seam adds 19,978 B. The published +9.0 kB unpacked result is the -package result, not a source-byte estimate. The increase is attributable to the named `mechanics` -closure, the injected root host composition, and preserving deferred helper loading while root -chunks are removed or repartitioned. The largest packed increase is the mechanics chunk; it is -offset by deletion or shrinkage of the superseded root Android chunks. - -A smaller design that retained root re-export shims, a broad barrel, or ambient filesystem/process -access would reduce gross source movement but violate this ADR's package-closure, -no-compatibility-shim, named-facet, host-injection, and implementation-lazy constraints. This is -exceptional W5 accounting, not reusable checkpoint headroom; after rebasing on #2116, the -combined-family CI size result remains the authoritative current measurement. - -Raw per-unit parity, planted-red, and size evidence belongs in #1739 and its PRs; this ADR retains -the decision, the evidence tiers, and the rule that each unit must justify exceptional growth -without treating checkpoint budget as an allowance. - -A command surface deprecated for the next major was deleted rather than migrated. Where a public -surface was narrowed, the narrowing merged first so the migration denominator was the surviving -surface only. - -Evidence requirements followed what a unit imported, in two tiers. A **request-scoped device unit** -used facts and operations only and proved its typed declaration and every fact cell. A -**durable-resource unit** additionally carried the full section 4–5 lifecycle evidence. These tiers -remain useful review vocabulary for future runtime changes, without reviving the migration ledger. - -The migration-time cutover table was retired at terminal completion. Facts are now the only support -authority for device commands, and the permanent R66 policy rejects descriptor capability buckets, -`requireCommandSupported` calls, and attempts to manufacture or repair narrowed runtime proof. +The checkpoint and recordings budgets paid for machinery that later units reuse. +They are not a precedent for growth. +Every post-checkpoint unit still defines its original-baseline cumulative bound and its immediate stack-base delta. +The default expectation is the inverse of those budgets. +A unit moves existing platform mechanics from root `src/` into platform packages and deletes superseded daemon code. +Thus the shipped-size delta of the unit trends to zero or negative. +The review of the unit reports root bytes removed against package bytes added. +Net growth is exceptional. +If net growth occurs, the unit names and justifies each contributing addition individually. +Contract modules stay vocabulary-thin under the existing capture-kit rule. +Per-unit type inflation is size growth, and review treats it as size growth. +Raw per-unit parity, planted-red, and size evidence belongs in #1739 and its PRs. +A unit must not treat the checkpoint budget as an allowance. + +A smaller design with root re-export shims, a broad barrel, or ambient filesystem/process access would reduce gross source movement. +It would violate these constraints of this ADR: package closure, no compatibility shims, named facets, host injection, and implementation laziness. + +Evidence requirements followed the imports of each unit. +A **request-scoped device unit** used facts and operations only. +It proved its typed declaration and every fact cell. +A **durable-resource unit** additionally supplied the complete lifecycle evidence from sections 4–5. +These tiers remain review vocabulary for future runtime changes. They do not restore the migration ledger. + +Completion retired the cutover table. +Facts are the only support authority for device commands. +Permanent R66 policy rejects descriptor capability buckets, `requireCommandSupported` calls, and attempts to manufacture or repair narrowed runtime proof. ### 9. Binding ergonomics: one bind per handler -The gateway exposes side-effect-free facts inspection for an ownership-resolved device. It answers -admission, `capabilities`, and doctor questions without creating a request binding, acquiring -helpers, or touching the device; it shares the owner's lazy loading. A handler binds exactly once, -with the execution use its plan selected. The admission-use idiom — binding with an empty required -set to read facts — is retired for new units: they may not introduce admission-only binds. Moving -an already-migrated handler onto facts inspection changes production admission behavior; it is its -own independently reviewed unit with parity evidence, never a side effect of a structural change. - -One second bind per request is permitted, and is not the retired idiom: a post-action observation -(`--settle`) whose action use carries no capture admits the `capture-only` plan for its own reads -(#3510). It is not an admission-only bind — it never reads facts through a binding, admits through -the facts probe, and binds only on admission — and where the action's use already required -`captureSnapshot` (`scroll top|bottom`, `scroll --until`) it projects the request's cached device -binding rather than acquiring a second one. It stays capture-only: a second bind that carries a -mutating operation is the execution use the plan should have selected. - -Use declarations share one neutral `defineUse`; per-domain currying wrappers add a module per -domain for no information and are not added. Required-only declarations are the default. Declaring -a preferred operation requires a recorded measurement of the fast path's benefit in the unit -review; the direct-selector fast path is the model. A preferred operation declared without a -measurement is speculative surface and is rejected in review. - -Declaring a conditional operation instead requires a parity artifact showing both sides: the owner -cells whose semantic path depends on the operation and the owner cells whose required path remains -complete without it. A conditional declaration justified only by speed belongs under `preferred`; -one justified only by universal correctness belongs under `required`. +The gateway exposes facts inspection without side effects for a device with resolved ownership. +It answers admission, `capabilities`, and doctor questions without a request binding, helper acquisition, or device access. +It shares the owner's lazy loads. +A handler binds exactly once with the execution use selected by its plan. +New units may not introduce admission-only binds with an empty required set to read facts. +A change from an already-migrated handler to facts inspection changes production admission behavior. +It is a separate unit with its own independent review and parity evidence. +It is never a side effect of a structural change. + +One second bind per request is permitted, and it is not the retired admission-only idiom. +A post-action observation (`--settle`) whose action use has no capture admits the `capture-only` plan for its own reads (#3510). +It never reads facts through a binding. +It admits through the facts probe and binds only after admission. +If the action already requires `captureSnapshot`, it projects the cached request binding instead of acquiring another. +This includes `scroll top|bottom` and `scroll --until`. +The second bind remains capture-only. +A second bind with a mutating operation is the execution use that the plan should have selected. + +Use declarations share one neutral `defineUse`. +Do not add per-domain currying wrappers; they add one module per domain without additional information. +Required-only declarations are the default. +A preferred operation requires a recorded measurement of its fast-path benefit in the unit review. +The direct-selector fast path provides the model. +Review rejects preferred operations without measurements as speculative surface. + +A conditional operation instead requires a parity artifact for both owner-cell groups: + +- Cells whose semantic path depends on the operation. +- Cells whose required path remains complete without it. + +A conditional declaration justified only by speed belongs under `preferred`. +A conditional declaration justified only by universal correctness belongs under `required`. ### 10. Process-lifetime and cross-cutting surfaces -A cross-cutting facet — one consumed from more than one command's execution path, such as snapshot -freshness, the system-chrome guard, presentation rules, or runtime hints — lands inside the first -command unit that consumes it and is named in that unit's review. Its contract has one owner. Later -units consume the existing facet; forking a parallel contract for the same concern is a seam -violation. A facet PR with no consuming command remains prohibited under section 6. - -Daemon startup gains no per-platform entrypoints. Startup recovery is evidence-gated: persisted -durable manifests and platform-owned markers are the only triggers, and a family's mechanics load -only when its evidence exists — preserving implementation laziness because an evidence-free family -is never evaluated. The app-log recovery path is the model. Remaining platform-specific startup -recovery (the Android test-IME marker, web browser orphans) moves onto this pattern when its owning -domain migrates, not before. - -Daemon shutdown is two-phase through the gateway. The detach phase runs before daemon session -teardown and offers each process-lived helper manager its handoff policy — a healthy kept-hot -helper may transfer to a successor daemon rather than stop. The stop phase runs after session -resources are finalized and terminates each still-owned generation at most once, per section 4. -Platform-specific shutdown calls have been deleted from the daemon runtime; owners participate -through those phases. - -Session teardown keeps its isolated-step skeleton and has no generic hook API. Each cleanup step -belongs to its domain: test-IME restoration is durable device state with marker-based recovery, -helper stops follow the owning platform module's lifecycle policy, and close-time cleanup consumes -neutral owner services. - -R65 is the daemon-side end-state enforcement: its planted-red AST tests reject every dependency edge — static, -dynamic, re-export, and type-only — from production `packages/daemon/src/**` modules (test files excluded, -matching the layering scanner's scope) to `src/platforms/**` and concrete -`@agent-device/platform-*` packages. R13 governs concrete package imports across the whole tree, -while `retired-platforms-zone` prevents the old root seam from being recreated; platform freedom is -therefore structurally enforced rather than periodically measured. +A cross-cutting facet serves more than one command execution path. +Examples include snapshot freshness, the system-chrome guard, presentation rules, and runtime hints. +It accompanies the first consuming command unit and appears in that unit's review. +Its contract has one owner. +Later units consume that facet. +A parallel contract for the same concern is a seam violation. +A facet PR without a consuming command is prohibited. + +Daemon startup gains no per-platform entrypoints. +Persisted durable manifests and platform-owned markers are the only triggers for startup recovery. +A family's mechanics load only when its evidence exists. +A family without evidence never evaluates, which preserves implementation laziness. +App-log recovery provides the model. +Remaining platform-specific startup recovery, namely the Android test-IME marker and web browser orphans, moves to this pattern when its owning domain migrates, not earlier. + +Daemon shutdown uses two gateway phases: + +1. Before daemon session teardown, detach offers each process-lived helper manager its handoff policy. + A healthy kept-hot helper may transfer to a successor daemon. +2. After session resources finalize, stop terminates each still-owned generation at most once, as section 4 requires. + +Platform-specific shutdown calls have been deleted from the daemon runtime. +Owners participate through these phases. + +Session teardown retains isolated steps and has no generic hook API. +Each domain owns its cleanup step: + +- Test-IME restoration is durable device state with marker-based recovery. +- Helper stops follow the owning platform module's lifecycle policy. +- Close-time cleanup consumes neutral owner services. + +R65 enforces the daemon end state through planted-red AST tests. +It rejects every dependency edge, whether static, dynamic, re-export, or type-only, from production `packages/daemon/src/**` to `src/platforms/**` and concrete `@agent-device/platform-*` packages. +It excludes test files, consistently with the layering scanner. +R13 governs concrete package imports across the repository. +`retired-platforms-zone` prevents restoration of the old root seam. +These gates enforce platform independence structurally, without periodic measurements. ### 11. The platform-services port carries host asks that bind no device -Some request-path work asks the host machine something without ever taking a device runtime: -concrete local readiness, boot-time and runner-session observation, and open-target -classification. Those asks now cross the daemon boundary as one required dependency, -`DaemonPlatformServices` (`packages/daemon/src/core/platform-services.ts`, exported from the package entry `@agent-device/daemon`), assembled by the daemon runtime's -root-composition module in `src/daemon-composition.ts` — which reaches the concrete adapters -through `src/platform-runtime-daemon-services.ts` behind a function-scoped `await import` — and -threaded composition → `startDaemonRuntime` deps → `RequestRouterDeps` → the request execution -scope → the handlers and consumers. Stale-claim reconciliation gets the same treatment -for the one act it may not perform itself: the composition supplies the `ClaimRecoveryGatewayFactory` -the daemon declares in `packages/daemon/src/resources/device/claim-recovery-gateway.ts`, built from -`src/platform-runtime-claim-recovery.ts` (likewise deferred), and the daemon keeps the transaction — -what to rebuild from (the dead owner's recorded state dir), in what order, and when to dispose it. - -The port is not a raw escape hatch back into platform mechanics. Its observation members are the -already-published neutral contract types from `@agent-device/contracts`, readiness is a boolean, -and the three open-target members are questions over neutral inputs (`DeviceInfo`, flag strings, -`SessionSurface`) declared at the port itself — none of them carries a raw selector, gesture, or -runner call, and no platform type appears in a signature; device -execution stays on the request-bound `RequestExecutionScope.bindDevice` path, whose owner facts -remain the only admission authority. Its members are also all required: there is no throwing -placeholder an un-composed process silently runs on — a daemon process either receives the root's -composition or cannot start, and a test passes the same shared fixture rather than mocking a -platform module behind the port. +Some request work needs host information without a device runtime: + +- Concrete local readiness. +- Boot-time and runner-session observations. +- Open-target classification. + +One required dependency carries these requests across the daemon boundary: `DaemonPlatformServices`. +`packages/daemon/src/core/platform-services.ts` declares it, and `@agent-device/daemon` exports it. +Root composition in `src/daemon-composition.ts` assembles it through `src/platform-runtime-daemon-services.ts` with a function-scoped `await import`. +It passes through composition, `startDaemonRuntime` dependencies, `RequestRouterDeps`, and the request execution scope to handlers and consumers. + +Stale-claim reconciliation uses the same pattern for the one act that it may not perform itself. +The daemon declares `ClaimRecoveryGatewayFactory` in `packages/daemon/src/resources/device/claim-recovery-gateway.ts`. +Composition supplies it from deferred `src/platform-runtime-claim-recovery.ts`. +The daemon retains the transaction: the dead owner's recorded state directory, reconstruction order, and disposal time. + +The port does not expose raw platform mechanics. +Its observation members use published neutral contract types from `@agent-device/contracts`. +Readiness is a boolean. +The port declares three open-target questions over neutral `DeviceInfo`, flag strings, and `SessionSurface` inputs. +No member carries a raw selector, gesture, or runner call. +No signature contains a platform type. +Device execution remains on `RequestExecutionScope.bindDevice`. +Owner facts remain the only admission authority. + +All port members are required. +There is no throwing placeholder for a process without composition. +A daemon receives root composition or cannot start. +Tests pass the same shared fixture instead of a platform-module mock behind the port. ## Relationship to prior decisions -- [ADR 0001](0001-provider-first-integration-scenarios.md): provider-first scenario coverage and - semantic provider operations remain. The monolithic `Interactor` and request callback-stack - topology are superseded as commands migrate. -- [ADR 0002](0002-persistent-platform-helper-sessions.md): persistent-helper protocols, - invalidation, and explicitly safe one-shot fallback remain. Daemon ownership means lifetime - policy; platform modules own helper managers/mechanics, and request bindings never own helpers. -- [ADR 0003](0003-daemon-command-registry.md) and - [ADR 0007](0007-remote-device-leases.md): daemon request-policy traits, lease admission, and lock - ordering remain daemon-owned. Binding happens only after their admission requirements are met. +- [ADR 0001](0001-provider-first-integration-scenarios.md): provider-first scenarios and semantic provider operations remain. + Command migration supersedes the monolithic `Interactor` and request callback-stack topology. +- [ADR 0002](0002-persistent-platform-helper-sessions.md): persistent-helper protocols, invalidation, and explicitly safe one-shot fallback remain. + Daemon ownership means lifetime policy. + Platform modules own helper managers/mechanics. Request bindings never own helpers. +- [ADR 0003](0003-daemon-command-registry.md) and [ADR 0007](0007-remote-device-leases.md): request-policy traits, lease admission, and lock order remain daemon-owned. + A binding occurs only after their admission requirements pass. - [ADR 0008](0008-command-descriptor-registry.md): the descriptor registry remains the command root. - Device-command capability buckets evolve into typed required/preferred/conditional runtime use - joined with exact runtime facts; inventory commands declare inventory use. + Device capability buckets become typed required/preferred/conditional runtime use with exact runtime facts. + Inventory commands declare inventory use. - [ADR 0009](0009-apple-platform-consolidation.md): the Apple family and `AppleOS` leaf axis remain. - The shallow `PlatformPlugin` shape is superseded as command units migrate; physical shared - mechanics move only through the legal injected substrate transition or after their last legacy - consumer. -- [ADR 0010](0010-error-system.md): normalized errors remain; this ADR adds operation/cleanup - precedence and typed reattachment outcomes. -- [ADR 0011](0011-interaction-guarantee-contract.md), - [ADR 0013](0013-unified-gesture-plans.md), and - [ADR 0014](0014-session-ref-frame-lifetime.md): interaction paths stay distinct, normalized gesture - plans cross the seam, and daemon authorization expires immediately before any possibly mutating - facet call. Platform implementations may not hide UI mutation inside an observational facet. -- [ADR 0018](0018-unified-event-journal.md) is proposed, not a dependency of this decision. If - accepted, its journal and teardown scopes may implement this ADR's observability contexts, while - remaining observation rather than coordination or persisted recovery state. + Command migration supersedes the shallow `PlatformPlugin`. + Shared physical mechanics move only behind a neutral lower-level capability that composition injects, or after their final legacy consumer. +- [ADR 0010](0010-error-system.md): normalized errors remain. + This ADR adds operation/cleanup precedence and typed reattachment outcomes. +- [ADR 0011](0011-interaction-guarantee-contract.md), [ADR 0013](0013-unified-gesture-plans.md), and [ADR 0014](0014-session-ref-frame-lifetime.md): interaction paths remain separate. + Normalized gesture plans cross the seam. + Daemon authorization expires immediately before any possibly mutating facet call. + Platform implementations may not hide UI mutation inside an observational facet. +- [ADR 0018](0018-unified-event-journal.md) remains proposed and is not a dependency. + If accepted, its journal and teardown scopes may implement this ADR's observability contexts. + They remain observation, not coordination or persisted recovery state. ## Consequences -The daemon keeps policy, command semantics, response construction, session/lease ownership, -selector/ref authority, interaction guarantees, and artifact orchestration. Platform packages gain -locality for native mechanics, provider transport composition, persistent helpers, and resource -recovery. Adding a platform implements existing runtime contracts and facts instead of adding daemon -tags, maps, callbacks, and branches. +The daemon retains these responsibilities: + +- Policy and command semantics. +- Response construction. +- Session/lease ownership. +- Selector/ref authority. +- Interaction guarantees. +- Artifact orchestration. + +Platform packages colocate native mechanics, provider transport composition, persistent helpers, and resource recovery. +A new platform implements existing runtime contracts and facts instead of new daemon tags, maps, callbacks, and branches. The cost was a staged cross-repository migration with temporarily coexisting execution systems. -Command-atomic cutover, explicit deletion boundaries, and the early adoption checkpoint kept that -coexistence shippable and abandonment-safe; the terminal repository retains only the closed -execution shapes and permanent structural policies. +Command-atomic cutover, explicit deletion boundaries, and the early adoption checkpoint kept each intermediate state shippable and safe to abandon. +The completed repository retains only closed execution shapes and permanent structural policies. ## Alternatives considered -- **Move all platform source into packages first:** rejected. It changes physical ownership before - proving the behavior seam, creates large root-import tunnels, and cannot be reviewed or abandoned - command by command. -- **Keep extending `PlatformPlugin` with tags and predicates:** rejected. It relocates selection data - but leaves the daemon mapping every platform tag back to behavior. -- **One replacement `Interactor`, universal dispatcher, or `SystemFacet`:** rejected. Platform leaf - support differs by operation, and a wide optional interface recreates unsupported stubs and - command-aware platform code. -- **Provider/local or old/new execution fallback:** rejected. Provider ownership and command cutover - must be exact; success-path fallback hides semantic divergence. Explicitly accepted internal helper - fallbacks remain governed by ADR 0002. -- **Use session events or a future event journal as the durable-resource store:** rejected. It would - make correctness depend on observability; current events are projections, and proposed ADR 0018 - explicitly preserves the same no-state-from-events rule. -- **A separate process-local resource ledger beside `SessionState` for live handles:** rejected. The - session store is in-memory, so live `SessionState` already is the process-local home; a parallel - handle/descriptor store would duplicate the ownership its R7 transition owner governs. The accepted - admission ledger is narrower: it holds only bounded cleanup-block evidence, contains no live handle - or descriptor, and never replaces the authoritative manifest. -- **Rely only on performance thresholds for lazy loading:** rejected. Thresholds catch regressions - late and can pass while unrelated implementation graphs load; the import/evaluation shape is also - contract-tested. +- **Move all platform source into packages first:** rejected. + This changes physical ownership before proof of the behavior seam. + It creates large root-import paths and prevents command-by-command review or abandonment. +- **Keep extending `PlatformPlugin` with tags and predicates:** rejected. + This moves selection data but leaves the daemon responsible for each tag-to-behavior map. +- **One replacement `Interactor`, universal dispatcher, or `SystemFacet`:** rejected. + Support differs by operation across platform leaves. + A broad optional interface restores unsupported stubs and platform code that knows commands. +- **Provider/local or old/new execution fallback:** rejected. + Provider ownership and command cutover must be exact. + Success-path fallback hides semantic differences. + ADR 0002 continues to govern explicitly accepted internal helper fallbacks. +- **Use session events or a future journal as the durable-resource store:** rejected. + Correctness would depend on observability. + Current events are projections. Proposed ADR 0018 also prohibits state reconstructed from events. +- **A separate process-local resource ledger beside `SessionState` for live handles:** rejected. + The session store is in-memory, so live `SessionState` already is the process-local home. + A parallel handle/descriptor store would duplicate ownership that the R7 transition owner governs. + The accepted admission ledger contains only bounded cleanup-block evidence. + It contains no live handle or descriptor and never replaces the authoritative manifest. +- **Rely only on performance thresholds for lazy loading:** rejected. + Thresholds detect regressions late and can pass despite unrelated implementation loads. + Contract tests also verify the import/evaluation shape. ## End state (proposed 2026-09-02, maintainer decision pending) -The targets below are proposed defaults, not an accepted commitment. No tracking issue or Status -line authorizes work against them yet; they exist to give the next migration wave a numeric -baseline and are open for revision or rejection. +The targets below are proposed defaults, not an accepted commitment. +No tracking issue or Status line authorizes work against them yet. +They provide a numeric baseline for the next migration wave and remain open for revision or rejection. **Daemon top-level footprint.** `git ls-tree --name-only origin/main src/daemon/ | grep -E -'\.ts$' | grep -vE '\.test\.ts$' | wc -l` measured 199 production files at `e624ef9d3f`. Proposed -target: ≤ 60. `packages/daemon/src/**` 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, re-measured for #2278 at `27a97ee619`: -the counting definition, ordered chains, hop roles, and commit for this measurement are in -[`0023-end-state-hop-trace.md`](./0023-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. +'\.ts$' | grep -vE '\.test\.ts$' | wc -l` measured 199 production files at `e624ef9d3f`. +The proposed target is ≤ 60. +`packages/daemon/src/**` remains a permanent R65 zone. +The target concerns its remaining contents, not directory retirement. + +**Entry-to-platform hop count.** The measurement was corrected on 2026-09-03 and repeated for #2278 at `27a97ee619`. +[`0023-end-state-hop-trace.md`](./0023-end-state-hop-trace.md) contains the definition, ordered chains, roles, and measured commit. +It supersedes the earlier numbers below. +A file-by-file re-trace at that HEAD measured 41 hops for `press`/Android and 47/49 for `snapshot`/iOS. +The snapshot route shares 30 hops, with 17/19 per arm: in-simulator AX bridge primary and XCTest runner fallback. +The earlier 38/29 counts had no ordered chain, definition, or artifact and do not reproduce. +They are superseded, not another data point. + +Decision sections 1–2 declare `src/platform-runtime.ts` and each façade's `loadRuntime` pair as boundaries, not pass-through layers. +They remain in every hop count regardless of target. +Other traced hops are pass-through candidates only where R13's named facets and the package direction already allow their collapse: +`kernel < contracts < host-kit < capture-kit < provision-kit < platform/provider/daemon`. +A hop that exists only to satisfy that direction is not waste. +The deletion test at the traced HEAD proves one distinct removable hop across both routes: `commands/runtime-types.ts`. +The earlier ≤ 14 target is **superseded and not reachable** without a decision to fold cross-cutting request-scope wrappers. +That decision is outside the traced routes' ownership. +The number 14 remains a historical discussion reference, not a proposed commitment. **Zones still under `src/` that this ADR expects to leave, and their package status:** -- `src/platform-runtime-*.ts` (~70 files: Android/Apple/screen-recording/perf/network/app-log/ - app-state/toolchain/device-inventory hosts). Mixed ownership: Android's deployment, logs, - network, perf, readiness, recording, and shutdown domains already have package homes under - `packages/platform-android/src/`; Apple's equivalents live under `packages/platform-apple/src/`. - The root files that remain are either declared host-port adapters (e.g. `adb-host`, bound only - by its named root file per Decision §1) or mechanics whose wave has not landed yet — the two are - not distinguished by filename and need per-file classification before a target is set. -- `src/recording`, `src/snapshot`, `src/snapshot-quality` — **migrated to - `packages/capture-kit/src/` (2026-09-07)**: the whole trees moved as-is (`recording/`, - `snapshot/` including its `snapshot-presentation/` and `snapshot-freshness/` subtrees), - together with the cross-package `warnings.test.ts` regression test. The four snapshot - modules from `src/core/` (`snapshot-chrome.ts`, `snapshot-state.ts`, - `snapshot-tree-ingestion.ts`, `snapshot-node-lookup.ts`, with their tests) joined the - package root. External consumers (daemon, commands, platform hosts, integration tests) - use package subpath exports (e.g. `@agent-device/capture-kit/snapshot-lines`, - `/recording-video`, `/ios-snapshot-runtime`); `snapshot-tree-ingestion` carries no - subpath because only `snapshot-state.ts` (same package) consumes it. Three tests stayed - at the root — `src/core/__tests__/snapshot-state.test.ts` and - `snapshot-chrome-android-statusbar.test.ts` (pinned to root-owned - `interaction-targeting` and the android UI-hierarchy fixtures) and - `src/__tests__/snapshot-desktop-surface.test.ts` (pinned to the root - eager-import-closure fixtures). -- `src/screenshot-diff/*.ts` (region split/overlay/summarization/component composition, with its - test) — **migrated to `packages/capture-kit/src/`** beside the pixel worker and screenshot - overlays it was built on. The CLI and command output consume it through - `@agent-device/capture-kit/screenshot-diff`, `/screenshot-diff-regions`, and - `/screenshot-diff-overlay-matches`. -- `src/provider-device-runtime.ts`, `src/provider-device-runtimes.ts`, - `src/provider-limrun-runtime.ts`, `src/provider-webdriver.ts`. Mostly already thin: the bulk of - WebDriver and Limrun provider logic lives in `packages/provider-webdriver/src/` and - `packages/provider-limrun/src/`; these root files are the composition-time wiring, comparable in - role to `src/platform-runtime.ts` itself. -- `src/core` (R13-governed consumer seams), `src/commands`, `src/cli`, `src/cli-schema`, - `src/mcp`, `src/client`, `src/sdk`, `src/ai-sdk`, `src/remote`, `src/request`. - Not migration targets: these are command-surface, protocol-projection, and - daemon-owned zones this ADR keeps in `src/` by design, not residue awaiting a package. -- React Native development tooling lives in `@agent-device/react-native`: Metro preparation and - session bindings, companion tunnels, component inspection, and render profiling share one - package. CLI output and SDK session normalization remain application adapters in `src/`. - -Neither number has an owning issue, gate, or accepted budget yet. Treat both as inputs to a -maintainer decision, not as a ratchet. +- `src/platform-runtime-*.ts` has approximately 70 Android/Apple/screen-recording/perf/network/app-log/app-state/toolchain/device-inventory host files. + Ownership is mixed. + Android deployment, logs, network, perf, readiness, recording, and shutdown already have homes in `packages/platform-android/src/`. + Apple equivalents reside in `packages/platform-apple/src/`. + Remaining root files are either declared host-port adapters or mechanics whose migration is incomplete. + For example, `adb-host` is a declared host-port adapter that only its named root file binds, per Decision section 1. + Filenames do not distinguish these roles. + A target requires per-file classification first. +- `src/recording`, `src/snapshot`, and `src/snapshot-quality` migrated to `packages/capture-kit/src/` on 2026-09-07. + This includes `recording/`, `snapshot/`, its `snapshot-presentation/` and `snapshot-freshness/` subtrees, and cross-package `warnings.test.ts`. + Four former `src/core/` modules and their tests joined the package root: + - `snapshot-chrome.ts` + - `snapshot-state.ts` + - `snapshot-tree-ingestion.ts` + - `snapshot-node-lookup.ts` + External daemon, command, platform-host, and integration consumers use package subpaths. + Examples are `@agent-device/capture-kit/snapshot-lines`, `/recording-video`, and `/ios-snapshot-runtime`. + `snapshot-tree-ingestion` has no subpath because only the same-package `snapshot-state.ts` consumes it. + Three tests remain at the root: + - `src/core/__tests__/snapshot-state.test.ts` and `snapshot-chrome-android-statusbar.test.ts` are pinned to root-owned `interaction-targeting` and the Android UI-hierarchy fixtures. + - `src/__tests__/snapshot-desktop-surface.test.ts` depends on root eager-import-closure fixtures. +- `src/screenshot-diff/*.ts` and its test migrated to `packages/capture-kit/src/` beside the pixel worker and screenshot overlays. + This covers region split, overlay, summarization, and component composition. + CLI and command output use `@agent-device/capture-kit/screenshot-diff`, `/screenshot-diff-regions`, and `/screenshot-diff-overlay-matches`. +- `src/provider-device-runtime.ts`, `src/provider-device-runtimes.ts`, `src/provider-limrun-runtime.ts`, and `src/provider-webdriver.ts` are mostly thin composition modules. + Most WebDriver and Limrun logic resides in `packages/provider-webdriver/src/` and `packages/provider-limrun/src/`. + The root modules serve a composition-time role comparable to `src/platform-runtime.ts`. +- These zones are not migration targets: + - `src/core`, with R13-governed consumer seams. + - `src/commands`. + - `src/cli`. + - `src/cli-schema`. + - `src/mcp`. + - `src/client`. + - `src/sdk`. + - `src/ai-sdk`. + - `src/remote`. + - `src/request`. + This ADR intentionally retains these command-surface, protocol-projection, and daemon-owned zones under `src/`. +- `@agent-device/react-native` owns React Native development tooling: + - Metro preparation and session bindings. + - Companion tunnels. + - Component inspection. + - Render profiling. + CLI output and SDK session normalization remain application adapters under `src/`. + +Neither number has an owning issue, gate, or accepted budget yet. +Treat both as inputs to a maintainer decision, not as a ratchet. diff --git a/docs/adr/0021-host-simlock-managed-device-allocation.md b/docs/adr/0021-host-simlock-managed-device-allocation.md index 6daa2c3dec..0e35e01d65 100644 --- a/docs/adr/0021-host-simlock-managed-device-allocation.md +++ b/docs/adr/0021-host-simlock-managed-device-allocation.md @@ -2,75 +2,87 @@ ## Status -Accepted (2026-09-01). Decision replaced 2026-10-10: the in-process Host allocator integration is -withdrawn. Simlock closed its side ([simlock#70](https://github.com/callstackincubator/simlock/issues/70)) -as superseded by a design in which agents lease devices themselves, and agent-device follows it. +Accepted (2026-09-01). The decision was replaced on 2026-10-10. The in-process Host allocator integration is withdrawn. +Simlock closed [simlock#70](https://github.com/callstackincubator/simlock/issues/70) in favor of direct device leases by agents. +agent-device follows that design. ## Rules at a glance -- Agents acquire booted devices from Simlock (`simlock lease`, its MCP tool, or its HTTP API) and - own renewal and release. agent-device does not call Simlock. +- Agents acquire booted devices from Simlock through `simlock lease`, the Simlock MCP tool, or the Simlock HTTP API. + Agents own renewal and release. agent-device does not call Simlock. - Simlock owns allocation, capacity, provisioning, readiness, expiry, and device deletion. -- agent-device drives a leased device as an ordinary local device: the local platform runtime, - ordinary sessions, and process-owned device claims. There is no managed runtime owner, allocator - client, allocation journal, managed binding fence, or Host lease mapping. -- iOS: pass the grant's `SIMLOCK_IOS_DEVICE_SET` as `--ios-simulator-device-set` (or the - `iosSimulatorDeviceSet` config key). Discovery, `simctl`, and the XCTest runner then resolve the - leased simulator inside Simlock's set. -- Android: the grant's `ANDROID_ADB_SERVER_PORT` points `adb` at Simlock's server. The daemon's - `adb` invocations read it from the daemon's own environment, so the daemon must start inside the - lease environment (for example, a per-agent `--state-dir`). +- agent-device uses the ordinary local runtime, sessions, and process-owned device claims for a leased device. + It has no managed runtime owner, allocator client, allocation journal, managed binding fence, or Host lease mapping. +- For iOS, pass the grant's `SIMLOCK_IOS_DEVICE_SET` as `--ios-simulator-device-set` or the `iosSimulatorDeviceSet` config key. + Discovery, `simctl`, and the XCTest runner resolve the leased simulator inside Simlock's set. +- For Android, the grant's `ANDROID_ADB_SERVER_PORT` selects Simlock's adb server. + Daemon `adb` calls read the daemon's environment. + The daemon must start inside the lease environment, for example with a per-agent `--state-dir`. ## 1. Context -Simlock keeps parallel agents from fighting over the same simulator or emulator. Its simulators -live in a device set Xcode does not read, and its emulators register with Simlock's own adb server, -which is what stops another tool from erasing a leased device. A grant reports what reaching the -device needs as an `environment` object (`SIMLOCK_IOS_DEVICE_SET`, `ANDROID_ADB_SERVER_PORT`), and -`simlock lease --export-env` prints it for `eval`. +Simlock prevents parallel agents from competing for one simulator or emulator. +Its simulators reside in a device set that Xcode does not read, and its emulators register with Simlock's own adb server, which is what stops another tool from erasing a leased device. +A grant reports device access requirements in an `environment` object with `SIMLOCK_IOS_DEVICE_SET` and `ANDROID_ADB_SERVER_PORT`. +`simlock lease --export-env` prints this object for `eval`. -The first version of this ADR planned a Host inside agent-device that would allocate through -Simlock's typed client, journal allocation requests, fence every command with confirmed lease -authority, and run managed devices through a dedicated runtime owner. agent-device shipped those -foundations without ever activating them. Simlock then settled on agents leasing devices directly, -which needs nothing from agent-device beyond addressing a device in Simlock's roots, and both -already existed: simulator-set scoping and adb's own server-port environment. +The first design proposed a Host inside agent-device with these responsibilities: + +- Allocate through Simlock's typed client. +- Journal allocation requests. +- Fence each command with confirmed lease authority. +- Use a dedicated runtime owner for managed devices. + +agent-device shipped these foundations but never activated them. +Simlock then selected direct device leases by agents. +This design needs nothing from agent-device beyond the ability to address a device in Simlock's roots. +agent-device already supported simulator-set scope and adb's server-port environment. ## 2. Decision -agent-device has no Simlock-specific code. An agent leases a device, points agent-device at it with -the scope Simlock reports, and releases the lease when done: +agent-device has no Simlock-specific code. +An agent leases a device and configures agent-device with Simlock's reported scope. +After use, the agent releases the lease. ```sh eval "$(simlock lease --platform ios --device 'iPhone 16' --detach --export-env)" agent-device open MyApp --platform ios --ios-simulator-device-set "$SIMLOCK_IOS_DEVICE_SET" ``` -The lease is the exclusion between agents; agent-device's process-owned device claims still guard -against two sessions of one installation driving the same device. Simlock's "advisory, not a -sandbox" model applies: agent-device does not verify that a device is leased. +The lease provides exclusion between agents. +Process-owned device claims still guard against two sessions of one installation that drive the same device. +Simlock's "advisory, not a sandbox" model applies. +agent-device does not verify that a device is leased. ## 3. Consequences -- The `managed-allocation` package, managed request admission and reachability, the `managed-local` - runtime owner kind, allocator-held device claims, and the managed device scope in platform - readiness and deployment are removed. A persisted durable-resource envelope naming a - `managed-local` owner decodes as unreattachable, and a schema-3 device-claim record as - inconsistent; no shipped production path ever wrote either. -- `device status` and `device release --stale` no longer report an `owner.kind: "allocator"` shape - or the `allocator-held` classification. Released v0.21.24 could print them but never produced them. -- Remote access to a leased device uses Simlock's own gateway or HTTP API, or agent-device's - existing `proxy` and remote daemon; neither depends on the other. -- The CLI and daemon take no per-request Android adb server port: one daemon talks to one adb - server, so agents holding leases from different Simlock daemons use separate state dirs. The Node - SDK's `agent-device/android-adb` functions run the caller's own adb executor, so the caller points - that executor at Simlock's server; the per-call `serverPort` in `AndroidAdbExecutorOptions` takes - effect only when that executor honors it. +- These components are removed: + - The `managed-allocation` package. + - Managed request admission and reachability. + - The `managed-local` runtime owner kind. + - Allocator-held device claims. + - Managed device scope in platform readiness and deployment. + A persisted durable-resource envelope with a `managed-local` owner decodes as unreattachable. + A schema-3 device-claim record decodes as inconsistent. + No shipped production path wrote either representation. +- `device status` and `device release --stale` no longer report `owner.kind: "allocator"` or `allocator-held`. + Released v0.21.24 could print these values but never produced them. +- Remote access to a leased device uses Simlock's own gateway or HTTP API, or the existing agent-device `proxy` and remote daemon. + Neither system depends on the other. +- The CLI and daemon accept no per-request Android adb server port. + One daemon uses one adb server. + Therefore, agents with leases from different Simlock daemons use separate state directories. + Node SDK `agent-device/android-adb` functions run the caller's own adb executor. + Therefore, the caller points that executor at Simlock's server. + Per-call `AndroidAdbExecutorOptions.serverPort` takes effect only if the executor honors it. ## 4. Withdrawn design -The replaced design required a durable allocation journal reconciled through Simlock after crashes, -fenced lease admission with renewal covering each command deadline plus teardown, a managed runtime -owner delegating to the local family, and allocator-held device claims whose principal was an -installation rather than a process. Recover it from git history (ADR text before 2026-10-10, -#2284, #2308, #2312) if an in-process allocator returns. +The replaced design required these components: + +- A durable allocation journal reconciled through Simlock after crashes. +- Fenced lease admission with renewal across each command deadline and teardown. +- A managed runtime owner that delegates to the local family. +- Allocator-held device claims with an installation principal instead of a process principal. + +If an in-process allocator returns, recover that design from git history: the ADR text before 2026-10-10, #2284, #2308, and #2312. diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 642f14c031..94ed5119b5 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -2,147 +2,162 @@ ## 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. - -Amended 2026-10-11 by #3431: the daemon-runtime composition edges this -ADR classified (the gateway/diagnostics/resource-cleanup/lifecycle edges, the provider-runtime -hubs, and the platform-services and claim-recovery dynamic edges) moved out of `packages/daemon/src/` into -the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it -is no longer a daemon-zone importer. #3430 then routed the leaked selector-query edge -(`packages/daemon/src/core/direct-ios-selector.ts`) through the request-bound runtime binding, so the R76 -inventory is empty; with nothing left to classify, the same change collapsed R76 to a plain ban on -the coupling and deleted the classification machinery, per the rule's own kill criterion. The -section-2 outcomes below remain the record of how each was classified. +Accepted (2026-09-06). This ADR implements issue #2278's audit deliverables. +Gates enforce the classification inventory and structural no-regrowth checks. +The ADR records decisions and references gates instead of duplicating the inventory. + +Amended 2026-10-11 by #3431. +Daemon-runtime composition edges moved from `packages/daemon/src/` to process-root `src/daemon-composition.ts`: + +- Gateway, diagnostics, resource-cleanup, and lifecycle edges. +- Provider-runtime hubs. +- Dynamic platform-services and claim-recovery edges. + +R76 excludes that root module because it is outside the daemon zone. +#3430 then moved the leaked `packages/daemon/src/core/direct-ios-selector.ts` query through the request-bound runtime binding. +The R76 inventory became empty. +With nothing left to classify, the same change reduced R76 to a plain ban on the coupling and deleted the classification machinery, per the rule's own kill criterion. +Section 2 retains the classification outcomes. Git history retains the completed classification procedure. + +## Rules at a glance + +- R76 prohibits any production daemon import of the root platform-runtime family or of a root hub that reaches it. + The family is `src/platform-runtime.ts` and `src/platform-runtime-*.ts`. +- Apple session observation uses neutral `AppleSessionObservation`. +- Runtime lifecycle participation deepens through the existing lifecycle phases. + It must not use a generic hook bag. +- Open-target plans/results remain separate from platform mechanics, with one construction path. +- R75 fails when a handler file enters the state-shape set or the store-authority set and the merge-base set does not contain that file. + Handler files may leave these sets. +- Route depth may collapse only pass-through or translation hops that the deletion test proves removable. + The only proven removable hop is `commands/runtime-types.ts`. It is the only endorsed collapse. + The ≤14 target is superseded. ## 1. Context -The platform-package migration removed direct production dependencies from `packages/daemon/src/**` 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). +R65 prohibits every form of direct production dependency from `packages/daemon/src/**` to concrete platform code. +The platform-package migration established this boundary. +It did not prove that each remaining platform-aware responsibility belongs in the daemon. +At baseline `658f822c`, nine daemon files had 13 production imports of root `src/platform-runtime-*.ts` composition modules. +At measurement commit `27a97ee619`, those nine files had 14 edges. +One file imports and re-exports 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`. +R7/R10 ratcheted `SessionState` write ownership. +They did not ratchet broad state reads or `SessionStore` authority. +The ADR 0019 trace recorded routes of approximately 24 files without an 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** (retired by the 2026-10-12 amendment below: the inventory - `DAEMON_PLATFORM_RUNTIME_EDGES` in - [`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts) - is now empty, so the classification table and its symbol-drift and staleness passes are deleted - and R76 bans the coupling outright — any production `packages/daemon/src/**` import of the family or of a - root hub that reaches it is a violation. The categories and outcomes below remain the record of - how each edge was judged and retired): - - **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`) - originally failed 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. Since the amendment it fails on any production daemon import of the - family or a hub that reaches it, in any static or dynamic form. - -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 identified as leaked platform mechanics have these owning interfaces or follow-ups: - - **Apple session observation** — the `AppleSessionObservation` contract supplies runner - liveness/session identity and the sole-foreground-app observation to recording health, - device refresh, and open-hint policy. Root composition supplies request-local inventory - and its error classifier; `packages/platform-apple` owns the probes. The three consumers - are daemon-policy-essential. Selector production and lifecycle participation stay separate. - - **#2333 (done)** — lifecycle participation of platform resource owners. The - `apple-runner-owner` and `operation-host` edges are retired; `daemon-runtime.ts` now holds - only the typed `PlatformOwnerLifecycle` contract (`packages/daemon/src/core/platform-owner-lifecycle.ts`), - composed at the root by `platform-runtime-daemon-lifecycle.ts`, which is the sole importer of - the Apple runner owner, the Android snapshot-helper and Web orphan cleanups, and legacy - app-log marker recovery. The `resource-cleanup` edge stays, reclassified - composition-essential and carrying only `platformResourceCleanup`. - - **Open-target planning (done, #2334)** — `platform-runtime-open-target.ts` keeps only the - neutral open plan/result surface (`resolveRequestedOpenSurface`, `validateOpenRelaunchTarget`, - `resolveSessionAppBundleIdForTarget`); Android package resolution - (`resolveAndroidPackageForOpen`, `inferAndroidPackageAfterOpen`) moved behind the Android - owning seam in `packages/platform-android`. `session-open-prepare.ts` consumes surface and - relaunch-target policy, `session-selector-dispatch.ts` consumes the app-bundle-identity - resolver; both edges are daemon-policy-essential, and the resolver stays the one - construction path for the open plan. - - **#2273/#2274 (done)** — the `direct-ios-selector.ts` → `queryAppleRuntimeSelector` edge is - retired. The live selector read is now the `querySelector` runtime operation on the - request-bound owner (`SelectorObservationRuntimeOperations`, declared CONDITIONAL on the - captured touch and gesture uses), so the off-screen rescue (#1542) executes on the binding - the command was already admitted for; no second selector producer was added. - -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 (`packages/daemon/src/{sessions,execution}/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 `packages/daemon/src/{sessions,execution}/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 - [`0023-end-state-hop-trace.md`](0023-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 session observation: consume the neutral - `AppleSessionObservation` contract. 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. 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). +1. **R76 prohibits daemon coupling to root platform-runtime modules.** + Any production `packages/daemon/src/**` import of the root platform-runtime family or of a root hub that reaches it violates R76. + This includes static and dynamic forms. + Git history retains the completed classification procedure. + The outcomes below retain its rationale. + +2. **Per-edge outcomes at `27a97ee619`.** + The audit retained four composition-essential edges for process-root assembly: + - `daemon-runtime.ts` → `platform-runtime.ts`: composed gateway. + - `daemon-runtime.ts` → `platform-runtime-host-diagnostics.ts`: host diagnostics. + - `device-claim-owner-recovery.ts` → `platform-runtime.ts`: owner recovery. + - `device-ready.ts` → `platform-runtime-device-ready.ts`: device readiness. + + The audit identified the remaining nine edges as leaked platform mechanics. Their owning interfaces or follow-ups are below: + - **Apple session observation.** + `AppleSessionObservation` supplies runner liveness/session identity and the sole-foreground-app observation. + Recording health, device refresh, and open-hint policy consume it. + Root composition supplies request-local inventory and its error classifier. + `packages/platform-apple` owns the probes. + The three consumers are daemon-policy-essential. + Selector production and lifecycle participation remain separate. + - **#2333 (done): platform resource-owner lifecycle participation.** + The `apple-runner-owner` and `operation-host` edges are retired. + `daemon-runtime.ts` holds only typed `PlatformOwnerLifecycle` from `packages/daemon/src/core/platform-owner-lifecycle.ts`. + Root `platform-runtime-daemon-lifecycle.ts` composes it. + That module alone imports the Apple runner owner, Android snapshot-helper cleanup, Web orphan cleanup, and legacy app-log marker recovery. + The `resource-cleanup` edge stays. It is reclassified as composition-essential and carries only `platformResourceCleanup`. + - **Open-target planning (done, #2334).** + `platform-runtime-open-target.ts` retains only the neutral open plan/result surface: + - `resolveRequestedOpenSurface` + - `validateOpenRelaunchTarget` + - `resolveSessionAppBundleIdForTarget` + Android `resolveAndroidPackageForOpen` and `inferAndroidPackageAfterOpen` moved behind the Android owning seam in `packages/platform-android`. + `session-open-prepare.ts` consumes surface and relaunch-target policy. + `session-selector-dispatch.ts` consumes the app-bundle-identity resolver. + Both edges are daemon-policy-essential. + The resolver remains the single construction path for the open plan. + - **#2273/#2274 (done): selector query.** + The `direct-ios-selector.ts` → `queryAppleRuntimeSelector` edge is retired. + The live selector read is the `querySelector` runtime operation on the request-bound owner (`SelectorObservationRuntimeOperations`). + That operation is declared CONDITIONAL on the captured touch and gesture uses. + Off-screen rescue (#1542) thus uses the command's admitted binding. + The change added no second selector producer. + +3. **Measure session state/store authority by symbol and ratchet the handler-owned portion.** + At `27a97ee619`, the repository overlay contains 112 `SessionState` shape edges and 68 `SessionStore` authority edges. + The handler-owned portion contains 14 shape files and 22 authority files under `packages/daemon/src/{sessions,execution}/handlers/**`. + R75 `session-authority-overlay` holds these file sets at or below the merge-base. + `measureRatchets` in `scripts/layering/ratchet-reference.ts` measures the reference. + Handlers may lose files from either set. They may never gain files. + The ratchet is added now that the edges are classified. + R7/R10 logical-module declarations own cross-module reads outside this handler path. + R75 does not ratchet those reads. + +4. **The entry-to-platform hop trace was re-run with hop roles and deletion tests.** + Roles are policy, orchestration, translation, adapter, and pass-through, with a terminal marker. + Each pass-through/translation hop receives a deletion test. + [ADR 0023](0023-end-state-hop-trace.md) records 41 hops for `press`/Android and 47/49 per arm for `snapshot`/iOS. + The snapshot route shares 30 hops, then adds 17 for the AX bridge or 19 for the runner fallback. + Only one distinct hop is proven removable: `commands/runtime-types.ts`. + The request-spine guards are side-calls, not hops: auth comparison, cancellation gate, idle-reap timer reset, and Android freshness guard/clear. + The earlier ≤14 target is superseded. + It is not reachable without folding cross-cutting request-scope wrappers, and the audit does not endorse that folding. + +5. **Decisions by audit area.** + - Apple session observation consumes neutral `AppleSessionObservation`. + - Runtime lifecycle participation deepens through the existing lifecycle phases, with no generic hook bag (#2333). + - Open-target planning separates plan/result from platform mechanics and preserves one construction path. + - Session state/store authority retains its access structure and ratchets the handler-owned portion through R75. + - Route depth records the re-traced routes and collapses only the proven pass-through hops. + This change collapses none. ## 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. +- **The inventory was a registry, not a prose duplicate.** + #2278's 13 imports were review candidates, not presumed violations. + A neutral runtime interface can be the correct owning seam. + Only a machine-readable table with per-edge categories made the classification decision enforceable by the layering gate. + It detected changed edges through drift and staleness failures, not warnings. +- **Categories 1 and 2 remain; category 3 needs deeper interfaces.** + Composition-essential edges assemble neutral implementations at the process root. + Daemon-policy-essential edges own request admission, session ownership, conflicts, locking, cancellation, teardown ordering, artifact publication, or response/event semantics. + Leaked platform mechanics are family operations or state that belong behind a deeper interface or adapter. + Only this third category required implementation work. + Three child issues for the nine leaked edges, instead of one refactor, keep each child issue independently reviewable. + They preserve the existing behavior that each surface depends on. + The selector edge is deliberately outside #2332. + #2273/#2274 already owned the selector seam; another producer would duplicate snapshot-producer policy. +- **R75 ratchets handler files, not the complete overlay.** + The repository's 112/68 sets include correct owning-module reads, such as session lifecycle reads of session state. + A repository-wide ratchet would prevent legitimate movement. + The handler-owned ratchet targets the exact regression that #2278 flagged: handlers that accrete state shape and store authority. + The fresh audit found no owner for this portion. +- **The hop target is retired, not missed.** + The routes measured at `132ffe1da` grew from 23/24 to 41 / 47-49. + New request-scope wrappers, the AgentDevice command layer, the adb host split, and dual-arm snapshot capture explain the growth. + All of these changes landed on the traced paths. + Every retained hop now has a documented role. + Each pass-through/translation hop also has a kept-depth or removable verdict. + The one proven removable hop is the only endorsed collapse. ## 4. Enforced by -- R76 `daemon-platform-runtime-inventory` — since the 2026-10-12 amendment a plain ban on the - coupling rather than a classification table (the inventory is empty and its machinery is - deleted) — 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 #2333 and #2334 landed (see §2.2); #2273/#2274 landed with the `querySelector` - runtime operation (see §2.2). +- R76 `daemon-platform-runtime-inventory` in `scripts/layering/check.ts`. + Since the 2026-10-12 amendment, it prohibits coupling instead of maintaining a classification table. + The inventory is empty and its machinery is deleted. +- R75 `session-authority-overlay` in `scripts/layering/check.ts`. + Both R75 and R76 failed against planted violations before acceptance. +- R7 `session-state-ownership` and the R10 merge-base ratchet for owning modules. +- R65, which prohibits concrete platform imports and supplies the basis for this audit. +- Child issues #2333 and #2334 landed; see §2.2. + #2273/#2274 landed with the `querySelector` runtime operation; see §2.2. diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index 2fa6393959..3dd528e9c8 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -1,78 +1,92 @@ # ADR-0023: end-state entry-to-platform hop trace -Backs the "Entry-to-platform hop count" paragraph in +## Status + +Reference trace for the entry-to-platform hop counts in ADR 0019 and the coupling audit in ADR 0022. + +## Rules at a glance + +- Count each distinct production `.ts` file once on the main path, including both endpoints. +- Exclude type-only imports, files that supply only types, side-calls, response-only files, and untraced forks from hop counts. +- Trace the snapshot runner fallback with an existing runner session. +- Assign exactly one role to each file. +- Apply the deletion test to each pass-through/translation hop. +- Treat 38/29 and the ≤14 target as superseded. + The audit endorses only the collapse of the proven removable `commands/runtime-types.ts` hop. + Any further target requires a decision to fold cross-cutting request-scope wrappers. + +This trace supports 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. - -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. +That paragraph cited 38 hops for `press`/Android and 29 for `snapshot`/iOS without an ordered route, counting definition, or linked artifact. +This file supplies all three. + +This revision replaces the superseded 23/24 tables recorded at `132ffe1da`. +It keeps the counting definition stable so the numbers remain comparable. ## 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 (`packages/daemon/src/server/http/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). +- **Unit:** one distinct production `.ts` file entered on the call path. + Count each file once, even on re-entry. + A lazy `import()` of an already-counted file adds no hop. + Type-only imports and files that supply only types add no hops. +- **Range:** from daemon HTTP entry `packages/daemon/src/server/http/http-server.ts` to the file that issues the concrete platform call. + Include both endpoints. + For `press`/Android, the terminal call is the serial-scoped `adb shell input tap` invocation. + For `snapshot`/iOS, it is AX bridge `socket.write` on the primary arm or XCTest runner `fetch()` on the fallback arm. +- **Main chain only:** follow the first call that continues toward the platform call. + Do not trace every branch. + `handleSnapshotCommands` also routes `alert`/`settings`/`diff`/`wait`; this trace follows only plain `snapshot`. + Side-calls that construct records within a hop do not count. + For example, `*RuntimeOperationFacts` builders inside per-request owner `inspectFacts` appear separately as admission fan-out. + Request-spine guards and liveness side effects also do not count: + - `timingSafeStringEqual` compares the auth token. + - `throwIfRequestCanceled` gates cancellation. + - `cancel()` resets the idle-reap timer. + - Android freshness guard/clear maintains freshness state. + These are sibling statements of the continuation, not calls that advance toward the platform. + Files that execute only after the platform call appear separately as response-path files. + The freshness clear is one such response call. +- **Steady state:** trace the snapshot fallback with an existing runner session. + The cold-start lease, xctestrun build through xcodebuild, and process launch form a separate fork. +- **Per-file role:** assign exactly one role: + - **policy:** decides admission, limits, retry, or routes and can refuse or redirect a request. + - **orchestration:** coordinates multiple calls, binds/runs a runtime, or assembles the object graph needed by later hops. + - **translation:** maps values/shapes without issuing the platform call, such as argv construction, wire encoding, or target resolution. + - **adapter:** encapsulates an external system boundary, for example a subprocess, a socket, or a fetch. + The terminal adapter issues the platform call. + - **pass-through:** delegates to exactly one next call on the path without a branch or transformation. + Examples include lazy-load indirection, a one-line re-export/delegate, and a scope wrapper. +- **Deletion test:** apply this test to each pass-through/translation hop. + Can the caller, the callee, or both inline the handoff of the hop without loss of admission, budget, isolation, a declared boundary named in an ADR, or a testability seam? + The test evaluates the hop, not the file. + A REMOVABLE hop is a collapse candidate, not a file to delete. +- **Method:** use this sequence: + 1. Read the file. + 2. Find the exported entry function that the caller reaches. + 3. Follow its first call that continues toward the platform call. + 4. Record the file and the one function that hands control to the next hop. +- **Commit measured at:** `27a97ee619`, re-traced for #2278. + It 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: `packages/daemon/src/sessions/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`). +The request spine also executes side-calls on every request. +They do not continue toward the platform boundary and therefore are not hops: + +- `packages/daemon/src/sessions/server/daemon-idle-reap.ts`: `cancel()` resets the liveness timer beside `dispatchRequest`. +- `packages/host-kit/src/internal/transport.ts`: `timingSafeStringEqual` compares the auth token before continuation. +- `packages/host-kit/src/internal/request-cancel.ts`: `throwIfRequestCanceled` gates the `task()` continuation inside `runLocked`/`runAdmitted`. | # | File | Hand-off | Role | Delete-test | |---|------|----------|------|-------------| -| 1 | `packages/daemon/src/server/http/http-server.ts` | parses `/rpc`, resolves the token; calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | -| 2 | `packages/daemon/src/server/http/daemon-runtime.ts` | `handleRequest`: in-flight count, cancels idle reap, `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | +| 1 | `packages/daemon/src/server/http/http-server.ts` | Parses `/rpc`. Resolves the token. Calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | +| 2 | `packages/daemon/src/server/http/daemon-runtime.ts` | `handleRequest`: counts in-flight requests; cancels idle reap; calls `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | | 3 | `packages/daemon/src/server/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 | +| 4 | `packages/host-kit/src/internal/diagnostics.ts` | `withDiagnosticsScope` calls the task within the request scope | pass-through | KEEP — per-request diagnostic attribution | +| 5 | `src/request/device-inventory-context.ts` | `withDeviceInventoryContext` calls the task within the scope | pass-through | KEEP — request-scoped device inventory context | +| 6 | `src/core/dispatch-resolve.ts` | `withResolveTargetDeviceCacheScope` calls the task within the scope | orchestration | KEEP — per-request target-resolution cache | | 7 | `packages/daemon/src/server/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 | `packages/daemon/src/sessions/request-handler-chain.ts` | `runRequestHandlerChain` routes to the interaction handler | orchestration | KEEP — command routing | @@ -84,10 +98,10 @@ before the continuation), and `packages/host-kit/src/internal/request-cancel.ts` | 15 | `packages/daemon/src/core/runtime-admission.ts` | `admitRuntimeOperations`: `inspectFacts`, per-op admission | policy | KEEP — per-operation admission | | 16 | `packages/daemon/src/core/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 | +| 18 | `src/platform-runtime.ts` | connects 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 | +| 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 | `packages/daemon/src/resources/device/device-claim-admission.ts` | `admit`: owner claim rule; ordinary owner | policy | KEEP — device claim admission | | 24 | `packages/daemon/src/execution/interaction/internal/interaction-touch-runtime.ts` | `dispatchRuntimeInteraction` → `createInteractionRuntimeForRoute` | orchestration | KEEP — interaction runtime dispatch | @@ -109,59 +123,91 @@ before the continuation), and `packages/host-kit/src/internal/request-cancel.ts` | 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: -`packages/daemon/src/execution/interaction/internal/interaction-touch-response.ts`, -`packages/daemon/src/execution/interaction/internal/interaction-common.ts`, -`packages/daemon/src/core/interaction-surface-signature.ts`, `packages/daemon/src/server/request-finalization.ts`, -`packages/session-journal/src/session-event-log.ts`, `packages/daemon/src/execution/session-snapshot.ts`, -`packages/daemon/src/core/recording-gestures.ts`, `packages/daemon/src/core/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`. +The terminal invokes host-kit's shared `runCmd` in `packages/host-kit/src/internal/command.ts`. +The snapshot route counts this generic subprocess adapter; this route adds no separate hop for it. +The baseline likewise ended at the route-level issue point, `adb.ts`, although the executor below it spawned the process. +At the traced HEAD, the equivalent boundary is `adb-host`. +Only its named composition module binds this named root file, per ADR Decision section 1. + +**Admission fan-out (not hops).** +The Android owner's per-request `inspectFacts` in `packages/platform-android/src/runtime.ts` calls 23 distinct `*RuntimeOperationFacts` builders as side-calls. +Each name below has a `-runtime.ts` suffix under `packages/contracts/src/`: + +- `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` + +**Response path (not hops).** These 8 files execute only after the platform call: + +- `packages/daemon/src/execution/interaction/internal/interaction-touch-response.ts` +- `packages/daemon/src/execution/interaction/internal/interaction-common.ts` +- `packages/daemon/src/core/interaction-surface-signature.ts` +- `packages/daemon/src/server/request-finalization.ts` +- `packages/session-journal/src/session-event-log.ts` +- `packages/daemon/src/execution/session-snapshot.ts` +- `packages/daemon/src/core/recording-gestures.ts` +- `packages/daemon/src/core/deferred-interaction-outcome.ts` + +**Forks not followed:** + +- `request-platform-provider-context.ts`: the router returns early for a bare local daemon with zero runtimes and a non-web target. +- `platform-runtime/request-providers.ts`: only `requestPlatformProviders.run` calls it; local execution does not. +- `lease-registry.ts`: `isHumanControlMutation` is false for an agent-surface press. +- `interaction-touch-press.ts`: the `@ref` and direct-iOS fast paths. +- `adb-provider-scope.ts`: the provider-adb arm. +- `adb-failure.ts`/`adb-host.ts`: error-path arms. ## `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 -(`packages/daemon/src/core/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. +The route branches at `packages/platform-apple/src/snapshot-route.ts` through `route.capture`, `isEligible`, and the per-generation circuit breaker. +The in-simulator AX bridge is primary. +The XCTest runner is fallback under any of these conditions: + +- `isEligible` returns false. +- AX acquisition fails. +- Single-flight target discovery has not settled within this capture's `TARGET_DISCOVERY_WAIT_MS` wait in `snapshot-target.ts`. + +The discovery probe continues. Later captures join it. +The shared prefix contains 30 files. +The primary arm contains 47 distinct files: 30 + 17. +The fallback arm contains 49 distinct files: 30 + 19. +The union contains 66 distinct files. + +Idle-reap reset, auth comparison, cancellation, and Android freshness guard/clear are side-calls on the prefix. +They execute beside the continuation and do not count as hops. +`packages/daemon/src/core/session-snapshot-freshness.ts` reads the guard, and after capture it clears the guard. +The clear has no effect on iOS. ### Shared prefix (entry → fork) | # | File | Hand-off | Role | Delete-test | |---|------|----------|------|-------------| -| 1 | `packages/daemon/src/server/http/http-server.ts` | parses `/rpc`, resolves the token; calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | -| 2 | `packages/daemon/src/server/http/daemon-runtime.ts` | `handleRequest`: in-flight count, cancels idle reap, `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | +| 1 | `packages/daemon/src/server/http/http-server.ts` | Parses `/rpc`. Resolves the token. Calls injected `handleRequest` | pass-through | KEEP — HTTP entry boundary | +| 2 | `packages/daemon/src/server/http/daemon-runtime.ts` | `handleRequest`: counts in-flight requests; cancels idle reap; calls `dispatchRequest` | orchestration | KEEP — request lifecycle orchestration | | 3 | `packages/daemon/src/server/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 | +| 4 | `packages/host-kit/src/internal/diagnostics.ts` | `withDiagnosticsScope` calls the task within the request scope | pass-through | KEEP — per-request diagnostic attribution | +| 5 | `src/request/device-inventory-context.ts` | `withDeviceInventoryContext` calls the task within the scope | pass-through | KEEP — request-scoped device inventory context | +| 6 | `src/core/dispatch-resolve.ts` | `withResolveTargetDeviceCacheScope` calls the task within the scope | orchestration | KEEP — per-request target-resolution cache | | 7 | `packages/daemon/src/server/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 | `packages/daemon/src/sessions/request-handler-chain.ts` | `runRequestHandlerChain` routes `snapshot` to `runSnapshotHandler` | orchestration | KEEP — command routing | @@ -175,11 +221,11 @@ on iOS) — run on this prefix as siblings of the continuation and are not count | 17 | `packages/daemon/src/core/session-runtime-admission.ts` | `admitRuntimePlan` (fan-out site) / `requireRuntimeBinding` | policy | KEEP — runtime plan fact admission | | 18 | `packages/daemon/src/core/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 | +| 20 | `src/platform-runtime.ts` | connects 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) | +| 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 and 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 | @@ -206,7 +252,7 @@ on iOS) — run on this prefix as siblings of the continuation and are not count | 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 | +| 16 | `packages/host-kit/src/internal/host-file.ts` | file operations for socket/bridge directories | adapter | KEEP — bridge socket file operations | | 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 @@ -233,100 +279,129 @@ on iOS) — run on this prefix as siblings of the continuation and are not count | 18 | `packages/platform-apple/src/runner/runner-startup-transport.ts` | `waitForRunner` → `tryRunnerRoute` → `postRunnerCommandOverRoute` | 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: -`packages/daemon/src/core/ref-frame.ts`, `packages/daemon/src/core/session-action-recorder.ts`, -`packages/daemon/src/sessions/snapshot-quality-latch.ts`, `packages/daemon/src/server/request-finalization.ts`, -`packages/session-journal/src/session-event-request.ts`, `packages/daemon/src/execution/session-snapshot.ts` (lineage), -`packages/daemon/src/execution/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. +Arm B #4 re-enters `packages/platform-apple/src/index.ts`, shared #22, through `applePlugin.createInteractor`. +The file counts once. + +**Admission fan-out (not hops).** +The Apple owner's per-request `inspectFacts` in `packages/platform-apple/src/runtime.ts` calls the same 23 builders listed for Android. +The builders are in `packages/contracts/src/*-runtime.ts`. They are side-calls of the admission step. + +**Response path (not hops).** These 22 files execute only after the platform call: + +- `packages/daemon/src/core/ref-frame.ts` +- `packages/daemon/src/core/session-action-recorder.ts` +- `packages/daemon/src/sessions/snapshot-quality-latch.ts` +- `packages/daemon/src/server/request-finalization.ts` +- `packages/session-journal/src/session-event-request.ts` +- `packages/daemon/src/execution/session-snapshot.ts` for lineage. +- `packages/daemon/src/execution/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` +- Modules under `packages/capture-kit/src/ios-snapshot-engine/`: + - 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`: the router returns early for a bare local daemon. +- `platform-runtime/request-providers.ts`. +- `lease-registry.ts`: `isHumanControlMutation` is false for an agent-surface snapshot. +- `handlers/snapshot.ts`: `alert`/`settings`/`diff`/`wait` arms. +- macOS surface-capture arms. +- `snapshot-runtime-binding.ts`: `selector-*`/`custom-actions-*` plan arms. +- Arm B's **cold-start chain**, because a steady-state session exists. + `startRunnerSessionWithLease` in `runner-session.ts` enters approximately 15 files under `packages/platform-apple/src/runner/`: + - `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. + It also enters host-kit `exec`/`atomic-file`/`host-process` for the xctestrun build, process launch, and listener-readiness 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. +The plan sheet suggested façade PRs #2178, #2222, and #2232 as a possible explanation. +All merged before `e624ef9d3f`, the commit cited for the ADR's 38/29 measurement. +Thus, they cannot explain 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. +This trace measures 41 hops for `press`/Android and 47/49 per arm for `snapshot`/iOS. +The ADR supplies no ordered chain, command/artifact, or definition for 38/29. +The discrepancy cannot be resolved against that text. +A different unit is the most likely explanation: perhaps named exports or all static/type imports instead of distinct production files on the call path. +**Treat 38 and 29 as superseded by these auditable measurements.** +They are not another data point for reconciliation. ## 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). +Both routes retain their old files. +The snapshot `register-builtins.ts` lookup moved from call time to module-load registration. +`platform-plugin-registry.ts` took its call-time role. +Four groups explain the growth: + +1. **Request-scope wrapper layer:** six new spine files on both routes: + - `daemon-runtime.ts` + - host-kit `diagnostics` + - `device-inventory-context.ts` + - `dispatch-resolve.ts` + - `request-execution-scope.ts` + - `provider-device-runtime.ts` + The former three-file spine, `http-server` → `request-router` → `request-handler-chain`, now contains nine files. + Diagnostics scopes, inventory/resolution contexts, execution scope/locks, and provider-scope isolation moved into the request path. + Auth comparison, cancellation, and idle-reap reset remain side-calls, not hops. +2. **AgentDevice command layer:** press rows 24–30 and snapshot shared rows 26–29. + Commands now use the bound AgentDevice surface between daemon dispatch and the platform interactor. + This includes `src/runtime.ts`, `src/commands/**`, and `interaction-runtime.ts`. +3. **adb host split and claim gate:** press rows 23 and 39–41. + The additions are `device-claim-admission.ts`, `adb-provider-scope.ts`, `adb-failure.ts`, and named-root terminal `platform-runtime-android-adb-host.ts`. + Package `adb.ts` is now a pass-through executor seam. +4. **Snapshot dual arms:** the in-simulator AX bridge became primary and the XCTest runner became fallback. + Arm A adds `snapshot-source/*`, target/process/simctl resolution, and host-kit process adapters. + Arm B adds runner protocol depth through `runner-command-traits`, `runner-contract`, `runner-recycle-ledger`, and `runner-startup-transport`. + Presentation moved to the response path in the 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. +This section re-derives the target at HEAD with the collapse rule that the earlier revision applied: fold every pass-through hop without kept depth. + +- **`press`/Android: no longer plausible.** + The fixed request spine, rows 1–9, already contains nine files. + Each of them passes the deletion test with kept depth (diagnostics attribution, inventory/resolution context, execution scope, or 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. + A ≤14 target would require removal of 27 files with cross-cutting daemon responsibilities or necessary bindings. + They are not unnecessary press-specific layers. +- **`snapshot`/iOS: not reachable.** + The shared prefix contains 30 files. + The primary arm adds 16 non-terminal hops of the AX-bridge protocol (binary cache, session lock, wire codec, deadline budgets). + The runner protocol of the fallback arm (retry, contract, recycle ledger, readiness) remains load-bearing. + Only shared row 28, `commands/runtime-types`, is REMOVABLE. + The collapse of this thin closure wrapper changes 47/49 to 46/48. + +**Verdict: the ≤14 target is superseded.** +At `27a97ee619`, the audited routes contain 41 hops for `press`/Android and 47/49 per arm for `snapshot`/iOS. +Only one distinct hop is proven removable across both routes: `commands/runtime-types.ts`. +The audit endorses only this reduction. +Any further target requires a decision to fold cross-cutting request-scope wrappers. +That decision is outside this route's ownership. +Treat 14 as a historical discussion reference, not a derived number. diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index b389009129..045d7a5191 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -2,140 +2,172 @@ ## Status -Accepted (2026-09-20; the pose-settle rule amended 2026-09-21 under #2730; the touch-overlay -export diagnosis corrected 2026-09-22 under #2707; the Android emulator pose added 2026-10-10). Covers iPhone Duo (iOS 27.1, `iPhone19,4`) and -any Apple device that reports more than one integrated CoreDevice display, and, through the amendment below, foldable Android emulators. - -An iPhone Duo carries two integrated panels — Apple's **outer display** and **inner display** — -and lights one of them at a time. Which one is lit is the device pose. Two independent facts -follow, and this ADR keeps them separate: **which panel a capture must name**, and **what pose the -device is in**. The first is answered by an official host API; the second only by inference. +Accepted (2026-09-20). +The pose-settle amendment is dated 2026-09-21 under #2730. +The touch-overlay export diagnosis was corrected on 2026-09-22 under #2707. +The Android emulator pose amendment is dated 2026-10-10. +This ADR covers iPhone Duo (iOS 27.1, `iPhone19,4`) and Apple devices with more than one integrated CoreDevice display. +The amendment below also covers foldable Android emulators. + +An iPhone Duo has Apple's **outer display** and **inner display**. +It lights one panel at a time according to device pose. +This ADR separates two facts: **which panel a capture must name** and **what pose the device is in**. +An official host API answers the first question. +Only inference answers the second question. ## Rules at a glance | Situation | Behavior | | --- | --- | -| Any iOS simulator capture | Resolve the CoreDevice display table first; capture the panel the system currently lights | -| Device has more than one integrated panel | Pass `--display=` on every `simctl io screenshot`; never rely on the implicit default | -| Panel power is ambiguous (zero or several lit panels) | Still name a panel — the `primary` one — emit `apple_display_capture_ambiguous`, and report the pose as `unknown` | -| Device has one integrated panel | Keep the pre-panel behavior exactly: no display flag, no pose, unchanged scale probe | -| Density normalization | Use the captured panel's own `pointScale`; a runner-fallback capture uses the pixels-per-point that capture reported about itself | -| Pose must be reported | From panel power alone, report `closed`, `fully-open`, or `unknown`, and never narrower; `fold` reports the exact pose because it reads the hinge angle | -| A pose change is requested | `agent-device fold `: send private HID inside the simulator (see headless amendment), then read the hinge angle back from CoreDevice until it agrees — for `half-open`, until two consecutive readings agree; refuse the pose if it never does | -| The hinge reaches `half-open` but keeps moving | Refuse it as `fold-pose-unsettled` with the observed and previous angles: an angle inside the open interval is an observed category, not a pose the hinge holds | -| An external display is attached | It is not a panel: it never makes the device multi-screen and never produces a pose | -| CoreDevice cannot answer | Return an unresolved inventory and keep the single-panel capture path; a missing host feature is not a capture failure | +| Any iOS simulator capture | First resolve the CoreDevice display table. Capture the panel that the system currently lights. | +| More than one integrated panel | Pass `--display=` on every `simctl io screenshot`. Never use the implicit default. | +| Zero or multiple lit panels | Name the `primary` panel. Emit `apple_display_capture_ambiguous`. Report pose as `unknown`. | +| One integrated panel | Preserve pre-panel behavior exactly: no display flag, no pose, unchanged scale probe. | +| Density normalization | Use the captured panel's `pointScale`. For runner fallback, use that capture's reported pixels-per-point. | +| Pose report | From panel power alone, report only `closed`, `fully-open`, or `unknown`, never a narrower pose. `fold` reports the exact pose because it reads the hinge angle. | +| Pose request | `agent-device fold ` sends private HID inside the simulator. Then it reads the hinge angle back from CoreDevice until the angle agrees. For `half-open`, two consecutive readings must agree. If the readings never agree, refuse the pose. | +| The hinge reaches `half-open` but keeps moving | Refuse with `fold-pose-unsettled` and the observed and previous angles. An angle inside the open interval is an observed category, not a pose that the hinge holds. | +| External display | It is not a panel. It creates neither multi-screen classification nor a pose. | +| CoreDevice cannot answer | Return unresolved inventory. Preserve single-panel capture. An absent host feature is not a capture failure. | ## Contracts -`packages/platform-apple/src/core/display-inventory.ts` owns the panel model and is the only -reader of CoreDevice display info. - -- **Authority.** `xcrun devicectl device info displays --json-output` (`jsonVersion` 5) is the only - screen authority. It reports per panel: `name` (which `simctl io --display` also accepts), - `displayId`, `nativeSize`, `pointScale`, `currentOrientation`, `type`, `primary`, `active`, and - `backlightState`. It works for simulators (`reality: simulated`) and physical devices alike. -- **Panel power.** `backlightState` is the authority and `active` is only a fallback, because - `active` is genuinely absent from real payloads: a single-panel iPhone 17 reports - `backlightState: activeOn` and no `active` key at all, so a parser keyed on `active` would call - that panel dark. The `BacklightState` union is `activeOn`, `inactiveOn`, `activeDimmed`, `off`, - `unknown`; the first three are lit. An unrecognized future case is `unknown`, not `dark`, because - guessing dark would aim a capture at a panel that may be showing nothing. -- **Panel identity.** A device is multi-screen when it reports more than one `type: integrated` - panel. CoreDevice marks one panel `primary`; that panel is the outer display, because it is the - panel that stays lit while the device is closed. `primary` and the lit count are the whole model: - the inventory labels panels `outer`/`inner` nowhere, because no caller consumes a label — every - decision is made from `primary` plus panel power, and a name derived from array order or from - geometry would be a second source of truth ready to invert silently. When the primary panel is - also the largest, the `primary` attribute still decides and the conflict is emitted as - `apple_display_primary_geometry_conflict` rather than silently repaired by a geometry rule. -- **Capture target.** `resolveAppleCaptureDisplay` returns a panel for every multi-panel device and - `undefined` only for a single-panel device or an unresolved inventory. When panel power identifies - exactly one lit panel that panel is named; otherwise the `primary` panel is named anyway, - `ambiguous` is set, and `apple_display_capture_ambiguous` is emitted. A multi-panel device never - receives a display-less capture, because the implicit default is precisely the black-and-exit-0 - failure being fixed; naming a possibly-dark panel is a visible, diagnosable miss instead. -- **No pose field.** Panel power cannot separate Apple's `UIHinge.Status.fullyOpen` from - `.partiallyOpen` — both leave the inner panel lit and the outer panel dark — so a derived pose - could only ever mean "not closed" while looking like a fact. The inventory therefore reports what - it measured (`primary`, panel power, geometry, orientation) and leaves pose to the app under test, - which can read `UIHinge.status` exactly. A caller that needs the pose asks the operator and - re-snapshots. -- **No cache.** The probe runs per capture. Which panel is lit is precisely what an operator - changes by folding or opening the device, so a cached inventory would resume capturing the dark - panel — the exact failure being fixed. The probe runs under - `IOS_APPLE_DISPLAY_PROBE_TIMEOUT_MS` (5s), deliberately below the 20s screenshot deadline it - precedes, so a wedged CoreDevice cannot spend the capture's own budget; measured real cost is - 0.16–0.26s against 4.93s for the screenshot. +`packages/platform-apple/src/core/display-inventory.ts` owns the panel model. +It is the only reader of CoreDevice display information. + +- **Authority.** + `xcrun devicectl device info displays --json-output` (`jsonVersion` 5) is the only screen authority. + It reports these per-panel values: + - `name`, also accepted by `simctl io --display`. + - `displayId`. + - `nativeSize`. + - `pointScale`. + - `currentOrientation`. + - `type`. + - `primary`. + - `active`. + - `backlightState`. + It works for simulators with `reality: simulated` and physical devices. +- **Panel power.** + `backlightState` is authoritative. `active` is only a fallback because real payloads can omit it. + A single-panel iPhone 17 reports `backlightState: activeOn` without an `active` key. + An `active`-only parser would classify this panel as dark. + The `BacklightState` union contains `activeOn`, `inactiveOn`, `activeDimmed`, `off`, and `unknown`. + The first three mean lit. + An unrecognized future value means `unknown`, not dark. + An assumed dark state could select a panel without content. +- **Panel identity.** + More than one `type: integrated` panel makes a device multi-screen. + CoreDevice marks one panel `primary`. + It is the outer display because it remains lit when the device closes. + The complete model uses `primary` and the lit count. + The inventory assigns no `outer`/`inner` labels because no caller consumes them. + Every decision uses `primary` and panel power. + Array order or geometry would create another authority that could silently invert identity. + If the primary panel is also largest, `primary` still decides. + In that case, emit `apple_display_primary_geometry_conflict`. Do not silently replace identity with a geometry rule. +- **Capture target.** + `resolveAppleCaptureDisplay` returns a panel for every multi-panel device. + It returns `undefined` only for a single-panel device or unresolved inventory. + If exactly one panel is lit, name that panel. + Otherwise, name `primary`, set `ambiguous`, and emit `apple_display_capture_ambiguous`. + A multi-panel device never receives a capture without a display name. + The implicit default caused the black-image, exit-0 failure. + An explicitly named, possibly dark panel makes failure visible and diagnosable. +- **No pose field.** + Panel power cannot distinguish Apple's `UIHinge.Status.fullyOpen` from `.partiallyOpen`. + Both light the inner panel and darken the outer panel. + A derived pose could therefore mean only "not closed", although it would appear factual. + The inventory reports measured `primary`, panel power, geometry, and orientation. + It leaves exact pose to the app under test through `UIHinge.status`. + If a caller needs pose, it asks the operator and takes another snapshot. +- **No cache.** + The probe executes per capture. + An operator changes the lit panel through a fold or open action. + Cached inventory could resume capture of the dark panel and recreate the failure. + `IOS_APPLE_DISPLAY_PROBE_TIMEOUT_MS` limits the probe to 5s, below the subsequent 20s screenshot deadline. + Thus, an unresponsive CoreDevice cannot consume the capture's own budget. + Measured cost is 0.16–0.26s, compared with 4.93s for the screenshot. ## Why the default capture had to stop being implicit -`simctl io screenshot` without `--display` picks the **highest** screen ID, not the primary panel. -On a closed Duo that is the inner panel, which is dark. The command exits 0 and writes a valid PNG -whose content is black, so every downstream consumer trusts a capture of nothing: +Without `--display`, `simctl io screenshot` selects the **highest** screen ID, not the primary panel. +On a closed Duo, this is the dark inner panel. +It exits 0 with a valid black PNG. Every downstream consumer trusts that capture of nothing. | Capture path | Before | After | | --- | --- | --- | | `screenshot` on a closed Duo | 669x951 @1x, mean luma 0.09 (black) | 466x678 @1x, mean luma 65.8 (the lit outer panel) | -| `screenshot` on an open Duo | 951x669 @1x, mean luma 241 (correct by luck: the highest screen ID is the lit inner panel) | 951x669 @1x naming `LCD-1` explicitly | +| `screenshot` on an open Duo | 951x669 @1x, mean luma 241 (the highest screen ID happens to identify the lit inner panel) | 951x669 @1x naming `LCD-1` explicitly | | `screenshot` on iPhone 17 | 402x874 @1x, mean luma 104.9 | unchanged | -This is the failure behind reports that the tool "picks the wrong screen" on iPhone Duo. It was -never a selection problem: only the lit panel is capturable, so naming the lit panel is the whole -fix, and no screen-selection flag is warranted. +This explains reports that the tool "picks the wrong screen" on iPhone Duo. +It was never a selection problem. +Only the lit panel is capturable. +Thus, the name of the lit panel is the whole fix, and a screen-selection flag is not warranted. -`SIMULATOR_MAINSCREEN_SCALE` is likewise fixed to one panel while the captured panel can be the -other, so density normalization now takes `pointScale` from the panel that was captured. +`SIMULATOR_MAINSCREEN_SCALE` also refers to one fixed panel. +A capture can use the other panel. +Density normalization therefore uses the captured panel's `pointScale`. ### Runner capture follows the app's window (#2728) -Naming the lit panel only helps a capture that asks CoreDevice. The runner captured -`XCUIScreen.main`, one fixed panel, so a capture reached through the runner — the fallback when -`simctl` fails, and every visual verification inside the runner — followed a panel chosen at build -time instead of the app. The runner now resolves the app window first, reads the display off that -same window instance, and encodes the capture upright, because the image handed back for a panel -the system presents turned has its buffer sideways. - -The capture reports the display ID it used, the pixel size it encoded, and the pixels-per-point of -that image, so density normalization reads the source it captured instead of applying a scale the -host inferred. A runner that reports nothing measured nothing, and normalization keeps the pre-panel -answer rather than borrowing a scale from a panel nobody captured. That payload is a cross-language -contract, not a per-consumer cast: `packages/contracts/src/screen-capture-contract.ts` owns the read, -the Swift struct owns the write, and those two types are the only declarations of the shape. The -golden table `contracts/fixtures/screen-capture-metadata.json` carries the wire key and two measured -poses for the vitest twin and the runner unit-test lane to decode through, so drift fails CI on both -sides at once. - -A capture asks the target app's window first and the system surface's window second. The home screen -is SpringBoard's window, so a capture with no session app — or with one that is not running — still -has a display that owns a window, and asking for it keeps the foldable rule intact: the answer is -always a display a window actually occupies, never `XCUIScreen.main`, which on a Duo is the dark -panel whenever the app is inside. A capture fails closed with a typed reason only when neither -resolves a window. An app with no window is refused on the query's own `exists` answer as well as on -its frame: a windowless app answers `frame` with `(0,0 0x0)` without raising, so geometry alone -cannot say "no window". - -A visual *check* on an app's own transition is a different question, and the system surface's second -answer would fabricate it. The `back` fallback's before/after comparison asks whether THIS app's -screen changed under a leading tap the app itself received; two SpringBoard captures of the home -screen are byte-identical no matter what the app did, so a system-surface sample would report -"unchanged" for exactly the failure it is meant to catch. The check therefore observes the app's own -resolved window and nothing else: an app that resolves no window reports the typed refusal as an -unknown outcome, not as the false no-change a second process would produce (#2728). - -Required and optional consumers of the same helper split on the frame they are owed. `screenshot` and -`record start`'s bootstrap frame are required — the recording sizes its whole writer from that first -image — and each names why the capture did not happen with the shared `APP_SCREEN_*` reason, so a -no-window runtime is a typed failure and never a generic one. The keyboard stability pump and the -recording pump keep a nil-tolerant frame, because a dropped frame between polls is normal and says -nothing about which display a window is on (#2728). - -Both answers are measured rather than assumed. A session with no app, and one whose app was terminated while still bound, answer the window query with `resolved=no`, and the capture that follows names the lit panel — on an unfolded Duo that is `LCD-1`, and the resulting home-screen image contains no black pixel. An app suspended by `home` keeps answering with a usable window, so it takes the first branch and still captures the panel it is on. - -`screenshot` is a runner-lifecycle command and skips the app-activation preflight, which left its -app as the runner host process on a fresh runner — a process with no window at all. It resolves the -requested bundle id for observation instead, and never activates an app to learn what it is -foregrounded on. Measured on a live Duo through the runner route, one command with unchanged flags: +A named lit panel helps only captures that ask CoreDevice. +The runner previously captured the fixed `XCUIScreen.main` panel. +This affected fallback after `simctl` failure and every runner visual verification. +The selected panel depended on the build instead of the app. +The runner now resolves the app window first and reads the display from that same window instance. +It encodes the capture upright because a turned panel supplies a sideways buffer. + +The capture reports its display ID, encoded pixel size, and image pixels-per-point. +Normalization uses this source metadata instead of a host-inferred scale. +If the runner reports nothing, it measured nothing. +In that case, normalization keeps its pre-panel result instead of borrowing a scale from a panel that nobody captured. +This payload is a cross-language contract, not a consumer-specific cast. +`packages/contracts/src/screen-capture-contract.ts` owns the read. +The Swift struct owns the write. +These are the only two declarations of its shape. +`contracts/fixtures/screen-capture-metadata.json` supplies the wire key and two measured poses. +The vitest twin and runner unit-test lane decode this golden table. +Contract drift therefore fails CI on both sides. + +Capture first asks for the target app's window, then the system surface's window. +SpringBoard owns the home-screen window. +Thus, a session without an app, or with a stopped app, can still resolve a window's display. +The result always names a display occupied by a window. +It never defaults to `XCUIScreen.main`, which is dark on a Duo when the app uses the inner panel. +A capture fails closed with a typed reason only when neither window resolves. +The query uses both `exists` and frame evidence to refuse an app without a window. +Such an app returns `(0,0 0x0)` from `frame` without an exception. +Geometry alone cannot prove that no window exists. + +A visual check of the app's own transition requires different evidence. +The `back` fallback compares before/after captures to determine whether this app changed after receiving a leading tap. +Two SpringBoard home-screen captures are byte-identical regardless of the app's behavior. +System-surface fallback would therefore report false "unchanged" evidence for the failure under test. +The check observes only the app's own resolved window. +If none resolves, typed refusal produces an unknown outcome instead of false no-change evidence from another process (#2728). + +Required and optional helper consumers differ in their frame requirements. +`screenshot` and the `record start` bootstrap frame require a capture. +The recording writer takes its dimensions from that first image. +Each uses the shared `APP_SCREEN_*` reason for capture failure. +A runtime without a window therefore returns typed failure, never generic failure. +Keyboard stability and recording pumps tolerate nil frames. +An absent frame between polls is normal and supplies no display-identity evidence (#2728). + +Measurements confirm both branches. +A session without an app and a bound session with a terminated app return `resolved=no` from the window query. +The subsequent capture names the lit panel. +On an unfolded Duo, it names `LCD-1`; the home-screen image contains no black pixel. +An app suspended through `home` retains a usable window. +It takes the first branch and captures its panel. + +`screenshot` is a runner-lifecycle command and skips app-activation preflight. +On a fresh runner, this previously selected the runner host process, which has no window. +It now resolves the requested bundle ID for observation. +It never activates an app to determine its foreground display. +A live Duo runner route measured these results with unchanged command flags: | Pose | Lit panel | Capture | Mean luma | | --- | --- | --- | --- | @@ -143,185 +175,196 @@ foregrounded on. Measured on a live Duo through the runner route, one command wi | Half-open (hinge 130°) | `LCD-1` (ID 3) | 951x669 @1x | 214 | | Closed (hinge 0°) | `LCD` (ID 1) | 466x678 @1x | 177 | -The closed row is the point: the same command followed the app onto the other panel without being -told, and every row is real content where a main-screen capture of the unlit panel is black. +The same command follows the app to the other panel in the closed pose without an explicit panel request. +Each row captures content; main-screen capture of the unlit panel returns black. ## Amendment: headless simulator HID replaces Device Hub (2026-09-22) -The guest HID experiment supersedes the Device Hub pose decision and its private-API rejection -below. UI discovery and sidebar sections, and their outstanding evidence gaps, are historical. -`fold` now runs a small Objective-C helper through `simctl spawn ` with no Device Hub or -host Accessibility requirement. `open` surfaces the standalone Simulator app after a cold boot and -has no Device Hub option. -The helper serializes `{provider: "com.apple.Virtualization.VirtualMachines", source: -"hinge-slider-control", type: "range", value: angle}` with IOCFSerialize and dispatches a vendor -HID event (usage page 0xff61, usage 0x5b, version 0) inside the simulator. Requested angles are -0°, 130°, and 180°; half-open preserves the observed Book angle, without promising UI animation -or orientation parity. CoreDevice readback and the half-open stability rule remain authoritative. -This explicitly accepts the private HID dependency for simulator folding. The selected Xcode -compiles the packaged source into a unique temporary directory per request; cleanup follows both -success and failure. Build and dispatch are bounded and cancellable, with no UI fallback. -Local headless tests verified all three poses, panel switching, and an inner-panel screenshot. -Physical devices, logged-out hosts, and hosted CI are unverified; Duo coverage remains local. -Absence of a public setter did not establish that Device Hub was required. +The guest HID experiment supersedes the Device Hub pose decision and its private-API rejection. +Git history retains that rejection, the obsolete UI discovery/sidebar process, and its evidence gaps. +`fold` runs a small Objective-C helper through `simctl spawn `. +It requires neither Device Hub nor host Accessibility. +After a cold boot, `open` shows the standalone Simulator app. +`open` has no Device Hub option. + +The helper uses IOCFSerialize for `{provider: "com.apple.Virtualization.VirtualMachines", source: +"hinge-slider-control", type: "range", value: angle}`. +Inside the simulator, it dispatches a vendor HID event with usage page 0xff61, usage 0x5b, and version 0. +Requested angles are 0°, 130°, and 180°. +Half-open preserves the observed Book angle without a promise of UI animation or orientation parity. +CoreDevice readback and the half-open stability rule remain authoritative. + +This decision explicitly accepts private HID for simulator folds. +The selected Xcode compiles packaged source into a unique temporary directory per request. +Cleanup occurs after success and failure. +Build and dispatch are bounded and cancellable, without UI fallback. +Local headless tests verified all three poses, panel changes, and an inner-panel screenshot. +Physical devices, logged-out hosts, and hosted CI remain unverified. +Duo coverage remains local. +An absent public setter did not prove that Device Hub was necessary. ### Timed hinge trajectories -`fold` accepts either a preset or 2–64 `{atMs, angle}` keyframes. Times start at zero and increase -strictly up to 60 seconds; angles stay in 0–180°. A single simulator process interpolates linearly -against a monotonic clock at approximately 60Hz, skipping missed frames instead of extending the -timeline. Equal angles express holds. Preparation and final verification are outside motion time. -Cancellation sends SIGTERM to simctl before a bounded SIGKILL escalation, allowing it to terminate -the guest helper. Abruptly killing simctl can orphan the animation. A live cancellation at two -seconds removed the guest process and left 67.9° unchanged beyond the five-second deadline. No automatic reset -runs on cancellation. Final custom angles require a readback within 0.5° and the existing interior -stability check; category agreement alone is insufficient. Shared golden fixtures define native -and TypeScript validation. Refs expire on successful or interrupted mutation through the existing -fold request policy. Hosted Duo tests remain local-only. +`fold` accepts a preset or 2–64 `{atMs, angle}` keyframes. +Times start at zero and increase strictly to at most 60 seconds. +Angles remain in 0–180°. +One simulator process interpolates linearly against a monotonic clock at approximately 60Hz. +It skips missed frames instead of extending the timeline. +Equal angles express holds. +Preparation and final verification occur outside motion time. +Cancellation sends SIGTERM to simctl, followed by bounded SIGKILL escalation, so simctl can terminate the guest helper. +Abrupt simctl termination can orphan the animation. +Live cancellation at two seconds removed the guest process. +The hinge angle stayed at 67.9° beyond the five-second deadline. +Cancellation performs no automatic reset. +Final custom angles require readback within 0.5° and the existing interior stability check. +Category agreement alone is insufficient. +Shared golden fixtures define native and TypeScript validation. +Existing fold request policy expires refs on successful or interrupted mutation. +Hosted Duo tests remain local-only. ## Pose control: Device Hub's control, CoreDevice's verdict -`agent-device fold` sets the pose, and the split above still holds: the press is not evidence, -the read-back is. The pieces, each of which was checked on the shipping 27.1 toolchain: - -| Piece | Finding | -| --- | --- | -| Who sets the pose | Device Hub's `CoreDevicePopDeviceKitExtension` (the V68 device view with its `poses` action bar) hands a "vendor defined" orientation-control payload to `CoreDevicePopCoreDeviceExtension`, which sends it through CoreDevice's private HID channel. No CLI, `simctl`, `devicectl`, or XCUITest surface reaches that channel | -| The public seam | The action bar's pose controls are ordinary `AXButton`s described `Closed`, `Book`, and `Open` in the Device Hub window; the simulated screen inside the same window is an `iOSContentGroup` with the app's own nodes. The earlier finding that the device surface exposes "zero accessibility nodes" was an artifact of System Events, which sees Device Hub with pid 0 because the app is launched through a trampoline; an `AXUIElement` built from the real pid works | -| Reading the pose | `xcrun devicectl device motion hinge-angle --device ` streams the hinge angle for the Duo simulator (`Range:0-180°`): Closed 0°, Book 130°, Open 180°. The stream does not end when `--session-timeout` elapses, so one read is bounded by devicectl's own `--timeout`, whose smallest accepted value is 5 seconds; the sample it printed before aborting itself is the reading | -| Device identity | Device Hub titles the window ` – iOS 27.1`, which two simulators sharing a name cannot distinguish. Its sidebar rows carry `AXIdentifier` `TableRow.Device.`, and setting `AXSelected` on a row switches the window to that device, so the row is `fold`'s only device handle — but a missing row plus a same-named twin's matching title skips the selection before any press, and the confirmation is by title too (see Accepted evidence gaps) | -| No window | A simulator booted headlessly leaves Device Hub running with no window. LaunchServices cannot address the trampolined process by bundle id (`open -b`, `NSRunningApplication.activate` do nothing), but a `kAEReopenApplication` event sent to the pid restores the device window, the same event a Dock click sends | - -The rule this yields: `closed` and `open` are the hinge's two end stops, so one read at the stop is -the pose. Every other angle is `half-open`, including the ones a hinge sweeps through on its way -somewhere else, so an angle in that open interval proves only the category — the pose is the hinge -*resting* there, which two consecutive reads show by both classifying as `half-open` and agreeing -within 0.5°. Each read takes the last sample the five-second stream printed, so a moving hinge is -reported where it is now. The run that first exposed the animation — before the amendment below — -read 175.1° one stream after pressing Book and 130° two streams later; the run recorded under the -amended rule is in "Verified on a booted Duo". A budget that ends with the hinge at some other pose is -refused as `fold-pose-unverified` with the angle CoreDevice still reports; one that ends on an -unsettled `half-open` angle is refused as `fold-pose-unsettled` (see the amendment below). The -response of a verified pose carries the angle and the lit panel's point -size, because the point size is what tells an agent its refs are stale. - -Requirements the command states in its own errors: Accessibility permission for the host -(`accessibility-permission`), a running Device Hub (`fold` launches it in the background the way -`open` does), a device window it can reopen (`device-hub-window-missing`), and a sidebar row for -the UDID (otherwise `device-hub-identity-unconfirmed`). A single-panel simulator is refused before anything is -pressed (`single-panel-device`), and the leaf fact refuses physical devices and every non-iPhone -simulator OS. - -Window discovery reads `AXWindows` from every matching Device Hub process within one shared -retry budget. These are application-wide windows; discovery does not click through monitors or -move windows to the main display. A failed accessibility read retains its AX status and returns -`device-hub-window-read-failed`, rather than masquerading as an empty window list. Only successful -empty reads trigger a reopen. A matching UDID sidebar row is required before pressing, including -when a window title already matches. An unrelated window does not stop attempts on other -windows or empty processes. A hidden sidebar is revealed through that window’s own toolbar button; -application-wide menus cannot redirect the action to another window. Sidebar reads and -restoration use bounded AX calls. Cleanup is registered before a sidebar press, since an AX reply -can time out after the action applied. Selection verifies the row's `AXSelected` state and the -window title even after an uncertain reply. Discovery passes the proven row to selection instead -of searching twice. Inconclusive candidates report `device-hub-identity-unconfirmed` with each -candidate's outcome; exhausting the shared budget before finishing work reports -`device-hub-discovery-timeout`. Neither claims a device is absent. Active host display IDs stay -in discovery error details; display geometry does not participate in AX window selection. +`agent-device fold` sets the pose. +The split above still holds: the press is not evidence, and the read-back is evidence. +The headless HID amendment above replaces the Device Hub press. +Git history retains the superseded Device Hub process. + +`xcrun devicectl device motion hinge-angle --device ` streams the Duo simulator hinge angle over `Range:0-180°`. +Measured presets are Closed 0°, Book 130°, and Open 180°. +`--session-timeout` does not stop the stream. +Each read therefore uses devicectl's own `--timeout`, whose minimum accepted value is 5 seconds. +The sample printed before timeout is the reading. +This read path was checked on the shipping 27.1 toolchain. + +`closed` and `open` are hinge end stops. +One read at the stop proves the pose. +Every other angle is `half-open`, including transient angles during movement. +An interior angle proves only a category. +A held pose requires two consecutive readings that both classify as `half-open` and differ by at most 0.5°. +Each read uses the final sample from the five-second stream, so it reports the hinge's current position. +Before the amendment, one run read 175.1° one stream after Book and 130° two streams later. +"Verified on a booted Duo" records the amended-rule run. + +If the budget ends at another pose, refuse with `fold-pose-unverified` and CoreDevice's observed angle. +If it ends at unsettled `half-open`, refuse with `fold-pose-unsettled`. +A verified response includes the angle and lit panel's point size. +That point size tells an agent its refs are stale. + +A single-panel simulator is refused before mutation with `single-panel-device`. +The leaf fact refuses physical devices and every non-iPhone simulator OS. ## Amendment: an observed half-open angle is not a settled pose (issue #2730) -The rule above first ended the other way: `half-open` was also reported when the four-read budget -ran out while the hinge still read `half-open`, because a refusal was not allowed to name the pose -that was asked for. That reasoning confuses an observed category with a completed pose change. -`half-open` is an open interval, so a hinge travelling between the end stops passes through it on -every fold; four readings that each land inside the interval and never agree describe a hinge in -motion, and reporting them as a pose tells an agent the Book preset is on screen when nothing is at -rest there. The interval rule also cannot pin a value: Device Hub's Book preset measures 130° on -this iOS 27.1 Duo, which is one device's measurement, not the success condition — stability is. - -So `fold half-open` succeeds only on two consecutive readings that both classify as `half-open` and -differ by at most 0.5°. Numerical proximity is not agreement when the pair straddles a category -boundary: 179° is `open` and 178.8° is `half-open` although they differ by 0.2°, and those are two -poses, not one resting hinge. A budget that ends on an unsettled `half-open` angle fails with -`COMMAND_FAILED`, `details.reason: "fold-pose-unsettled"`, `requestedPose`, `observedPose`, -`hingeAngleDegrees` and the previous reading, and says the hinge was observed half-open and did not -settle — never that it failed to reach half-open, which its own observed pose refutes. -`fold-pose-unverified` stays for a budget ending on another pose, and keeps classifying from that -final sample alone: a hinge that passed through `half-open` on its way to the end stop is refused as -unverified, because the pose it ended in is not the one that was asked for. -The attempt count, the per-read timeout, cancellation, and the open/closed end-stop -rule are unchanged: an unsettled fold costs the same four hinge streams it always did. - -A first hinge read that returns no sample leaves the pose unconfirmed, whatever the cause: devicectl 651.13.5 (Xcode 27.1 RC build 27A9275) has no `device motion` subcommand, and an unreachable device prints no sample either. The HID command has already been delivered by then, so `fold` refuses with `fold-pose-dispatched-unverified`: sent, not confirmed, distinct from a pose the hinge contradicted. The read's own failure rides along as `hingeReadFailure`, so the cause is read from its stderr rather than assumed. When panel power decides a single lit panel, the error carries it as `screen` evidence; panel power cannot separate `half-open` from `open`, so it is never promoted to a verified pose. A no-sample read after an earlier sample is not this case and keeps its own reason. +The former rule also reported `half-open` when the four-read budget ended while the hinge still read `half-open`. +The reason was that a refusal was not allowed to name the requested pose. +This confuses an observed category with a completed pose change. +A hinge crosses the half-open interval on every movement between end stops. +Four readings inside the interval without agreement describe motion, not a held Book preset. +The interval also cannot specify one angle. +The Device Hub Book preset measured 130° on this iOS 27.1 Duo. +That measurement is device-specific; stability is the success condition. + +`fold half-open` succeeds only when two consecutive readings classify as `half-open` and differ by at most 0.5°. +Numerical proximity across a category boundary is insufficient. +179° is `open`, while 178.8° is `half-open`. +Their 0.2° difference does not prove one held pose. +If the budget ends at unsettled `half-open`, fail with `COMMAND_FAILED` and these details: + +- `details.reason: "fold-pose-unsettled"` +- `requestedPose` +- `observedPose` +- `hingeAngleDegrees` +- The previous reading. + +The message says the hinge was observed half-open but did not settle. +It never claims failure to reach half-open, which the observation disproves. +`fold-pose-unverified` remains the reason when the budget ends at another pose. +It classifies only the final sample. +A hinge that passes through `half-open` and ends at an end stop is refused as unverified, because its final pose is not the requested pose. +Attempt count, per-read timeout, cancellation, and open/closed end-stop rules remain unchanged. +An unsettled fold still consumes four hinge streams. + +If the first hinge read returns no sample, the pose remains unconfirmed regardless of cause. +For example, devicectl 651.13.5 in Xcode 27.1 RC build 27A9275 lacks `device motion`. +An unreachable device also prints no sample. +HID has already dispatched, so refusal uses `fold-pose-dispatched-unverified`. +This distinguishes unconfirmed dispatch from an observed contradictory pose. +`hingeReadFailure` retains the read failure. +Its stderr supplies the cause instead of an assumption. +If panel power identifies exactly one lit panel, the error includes `screen` evidence. +Panel power cannot distinguish `half-open` from `open`, so the `screen` evidence is never promoted to a verified pose. +A later no-sample read after an earlier sample retains its own reason. ## Pose is derived from panel power, and official control does not exist -Apple ships fold state as an **app-side, read-only** API: `UIHinge.status` -(`.closed`/`.partiallyOpen`/`.fullyOpen`) observed through `UIHingeInteraction`, and SwiftUI -`DeviceHinge` / `.onHingeChange`, both `ios(27.1)`. There is no `UITraitCollection` trait for it. +Apple's **app-side, read-only** API exposes `UIHinge.status` as `.closed`/`.partiallyOpen`/`.fullyOpen` through `UIHingeInteraction`. +SwiftUI exposes `DeviceHinge` / `.onHingeChange`. +Both require `ios(27.1)`. +There is no `UITraitCollection` hinge trait. -No **official** host-side control channel exists. Each candidate was checked against the shipping -toolchain: +No **official** host control channel exists. +The shipping-toolchain investigation recorded these candidate results: | Candidate | Result | | --- | --- | -| `simctl` subcommands | no `hinge`/`fold`/`pose` surface; no such token in the `simctl` binary or in `CoreSimulator.framework` | -| `simctl io` | `screenshot`/`recordVideo` take `--display`; `screenConfig` sets `power` and `geometry` per screen — neither changes hinge state | -| XCUITest | no hinge token in any `XCUIAutomation` header; `XCUIDeviceButton` is Home/VolumeUp/VolumeDown/Action/Camera only | -| CoreDevice | `com.apple.coredevice.action.streamhingeangle`, `HingeAngleManager`, `DeviceHingeAngleSnapshot` and `HingeAngleStreamConfig` exist in `/Library/Developer/PrivateFrameworks/CoreDeviceUtilities.framework` (and `dtdeviceinfod`), but they **stream** an angle; the Duo reports no such feature, and `devicectl` exposes no CLI for it | +| `simctl` subcommands | No `hinge`/`fold`/`pose` surface; No such token in the `simctl` binary or in `CoreSimulator.framework` | +| `simctl io` | `screenshot`/`recordVideo` take `--display`; `screenConfig` sets `power` and `geometry` per screen — Neither changes hinge state. | +| XCUITest | No hinge token in any `XCUIAutomation` header; `XCUIDeviceButton` is Home/VolumeUp/VolumeDown/Action/Camera only | +| CoreDevice | `com.apple.coredevice.action.streamhingeangle`, `HingeAngleManager`, `DeviceHingeAngleSnapshot` and `HingeAngleStreamConfig` exist in `/Library/Developer/PrivateFrameworks/CoreDeviceUtilities.framework` (and `dtdeviceinfod`), They **stream** an angle. The Duo reports no such feature. `devicectl` exposes no CLI for it. | | `devicectl` | `device info displays` reports panel state only | -| `Xcode.app` frameworks | no hinge reference in `Xcode.app/Contents/{SharedFrameworks,Frameworks}`, so Device Hub's own control is not a hinge API shipped inside Xcode | - -Two searches came back empty rather than negative, and are recorded as limits rather than refuted -claims: - -- The **simulator guest runtime** was not enumerated for a host-reachable control channel. Symbol - archaeology turned up a private SpringBoard service (`SBDisplayToolService`, - `com.apple.springboard.sbdisplay.service`) with `setPrimary:displayUUID:`, - `setBacklightState:displayUUID:` and `replayHingeSamplesWithOptions:path:`, plus - `SBContinuousFoldController` and a `com.apple.springboard.fold` notification. No client, launch - path, or invocation from this host was found, so it is an **unproven private lead** — not adopted, - and not a basis for any claim that pose control is possible. -- Device Hub's control could live in a private framework outside `Xcode.app`. That would not make it - usable: it is still not an API. - -Consequences that are now policy: no private per-guest XPC channel is driven, because an undocumented -hook is exactly the kind that breaks without notice; the one host control that exists is Device -Hub's own, and `fold` drives it through the accessibility API and trusts only the CoreDevice -read-back (see the section above). A pose derived from panel power alone is still documented to -cover Apple's `fullyOpen` **and** `partiallyOpen` — panel power cannot separate them, so only the -hinge angle or an in-app `UIHinge.status` read can. +| `Xcode.app` frameworks | No hinge reference in `Xcode.app/Contents/{SharedFrameworks,Frameworks}`, Device Hub control is therefore not a hinge API shipped inside Xcode. | + +Two searches came back empty, not negative. This ADR records them as limits, not as refuted claims: + +- The investigation did not enumerate the simulator guest runtime for a host-reachable control channel. + Symbol searches found private SpringBoard `SBDisplayToolService`, `com.apple.springboard.sbdisplay.service`, and these methods: + - `setPrimary:displayUUID:` + - `setBacklightState:displayUUID:` + - `replayHingeSamplesWithOptions:path:` + They also found `SBContinuousFoldController` and a `com.apple.springboard.fold` notification. + No client, launch path, or invocation from this host was found. + This is an **unproven private lead**, not an adopted mechanism or proof of possible pose control. +- Device Hub's control could reside in a private framework outside `Xcode.app`. + That would still not provide a usable API. + +Panel-power-derived pose still covers Apple's `fullyOpen` **and** `partiallyOpen`. +Only hinge-angle readback or in-app `UIHinge.status` distinguishes them. ## Refuted alternatives -- **Pose commands backed by `simctl io screenConfig power`.** Rejected: it does not move - `UIHinge.status`, so the app under test would not behave as folded. It would produce green tests - of a state the device is not in. -- **Pose commands backed by coordinate clicks on Device Hub.** Rejected: a coordinate press is - blind to which window and which device it lands on and needs Screen Recording to aim. The - accessibility press `fold` uses names the control, the window, and the device row, needs only - Accessibility permission, and is still not trusted on its own: the hinge read-back is. -- **A `fold` that reports the pose it requested.** Rejected: the press is dispatched to whatever - Device Hub window is frontmost for that device, and a press on the wrong window succeeds - silently. Only the CoreDevice hinge angle says the device moved. -- **Luma or content heuristics to pick the lit panel.** Rejected: a black screenshot is legitimate - content elsewhere, and the repo already forbids deciding on pixels when a typed fact exists. - CoreDevice reports `active`/`backlightState` directly. -- **A `--screen outer|inner` flag.** Rejected as unnecessary: the inactive panel renders black, so - a non-active selection can only ever capture nothing. Revisit only when a device shows content on - two panels at once. -- **Reading the device type's `capabilities.plist` for the panel table.** Rejected: `devicectl` - reports live panel state, which the plist cannot, and works identically on physical devices. +- **Pose commands backed by `simctl io screenConfig power`: rejected.** + It does not change `UIHinge.status`. + The app would not behave as folded, and tests could pass for a state absent from the device. +- **Pose commands backed by coordinate clicks on Device Hub: rejected.** + Coordinates cannot identify the target window/device and require Screen Recording for alignment. + The superseded accessibility approach named the control, window, and device row and required only Accessibility permission. + That approach still required hinge readback instead of trusting the press. +- **A `fold` that reports the requested pose: rejected.** + A press on the wrong Device Hub window can silently succeed. + Only CoreDevice hinge-angle readback proves movement of the selected device. +- **Luma or content heuristics for panel selection: rejected.** + Black screenshots can represent valid content. + Repository policy prohibits pixel-based decisions when typed facts exist. + CoreDevice directly reports `active`/`backlightState`. +- **A `--screen outer|inner` flag: rejected as unnecessary.** + The inactive panel renders black, so its capture contains no content. + Revisit only when a device shows content on two panels simultaneously. +- **The device type's `capabilities.plist` as panel inventory: rejected.** + It lacks the live panel state that `devicectl` reports. + `devicectl` also works identically on physical devices. ## Consequences for agents -A pose change moves the app to a different panel with different point size, so refs and -coordinates do not survive it. `agent-device help foldable` states this, `fold` says so in its own -message, and agents fold to each pose a task names and re-snapshot rather than assume one. +Pose changes move the app to a panel with a different point size. +Refs and coordinates do not survive the change. +`agent-device help foldable` and `fold`'s message state this requirement. +Agents fold to each requested pose and take another snapshot instead of assuming pose. -`simctl io recordVideo` has the same implicit-display default as `screenshot`, so recording names -the lit panel through the same resolver. On an open Duo the 27.1 toolchain accepts the panel name -and honors it per panel; sampled mean luma over the whole frame: +`simctl io recordVideo` shares screenshot's implicit display default. +Recording therefore names the lit panel through the same resolver. +On an open Duo, the 27.1 toolchain accepts and honors the panel name. +The measurements sample mean luma across the complete frame: | `recordVideo` argv | exported size | mean luma | | --- | --- | --- | @@ -329,40 +372,43 @@ and honors it per panel; sampled mean luma over the whole frame: | `--display=LCD` (dark outer) | 1398x2034 | 0.00 | | no `--display` | 2006x2852 | 241.42 | -`record start`/`record stop` exit 0 in both poses, and with `--hide-touches` the export keeps the -captured geometry (`2006x2852`, mean luma 241.42). Without it, and before #2707 was fixed, the -touch-overlay exporter lost the track geometry, and on a long clip the frames too. +`record start`/`record stop` exit 0 in both poses. +With `--hide-touches`, export preserves captured geometry: `2006x2852`, mean luma 241.42. +Before #2707's fix, touch-overlay export without that flag lost track geometry and, on long clips, frames. ### The touch-overlay export lost geometry for any capture, not a rotated one (#2707) -The trigger was the overlay drawing touch events, not panel rotation. The #2707 report measured this -on an iPhone 17 simulator (iOS 27.0, non-rot90 panel), Xcode 27.1 beta: four seconds with no -interaction exported `1206x2622` intact, ten seconds containing two taps exported `220x480`, and the -same two taps under `--hide-touches` exported `1206x2622` with the screen content changing across -frames. A 97-second recording with touches exported `480x220` at mean luma 0.00 throughout. - -An earlier draft of this section blamed the inner panel's `rot90` track. That was wrong and predated -this matrix: every failing sample then available had merely been captured on that panel, and the one -non-rotated sample that looked intact had contained no touches to draw. Feeding an untouched raw -`simctl` capture straight into `recording-overlay.swift` just copies it through, which is why -`--hide-touches` and an empty gesture list kept the capture intact — the collapse lived in the -overlay export path, not the panel. - -The cause was the export preset, measured against a synthetic capture on this host: the burn-in is a -full re-encode through `AVAssetExportSession`, and the default `medium` tier selected -`AVAssetExportPresetMediumQuality`, a fixed-canvas preset that rescales the long edge to 480px — a -`1206x2622` capture lands on `220x480`, a landscape capture on `480x220`. Only -`AVAssetExportPresetHighestQuality` preserves arbitrary capture geometry, and the hardware encoder -makes the full-resolution re-encode cheap: 90s at `1206x2622` re-encoded in ~1–2s, so `high` was -never actually slower. - -`recording-overlay.swift` now always exports through the geometry-preserving preset at both quality -tiers, so `--quality` no longer trades capture resolution away. It also verifies its own output -before the caller adopts it — the composited track's resolved size must match the capture, and a -track that went uniformly black while the raw had visible content is rejected — and on either failure -it throws instead of publishing a broken file, so the overlay is dropped, the raw capture is kept, -and the choice is reported on the `record stop` response as `overlayWarning`. Feeding the fixed tool -the same synthetic captures re-measures the failing rows as matching `--hide-touches`: +Touch-event overlays caused the failure, not panel rotation. +#2707 measured an iPhone 17 simulator with iOS 27.0, a non-rot90 panel, and Xcode 27.1 beta: + +- Four seconds without interaction exported intact `1206x2622`. +- Ten seconds with two taps exported `220x480`. +- The same taps with `--hide-touches` exported `1206x2622` with changing screen content. +- A 97-second touched recording exported `480x220` with mean luma 0.00 throughout. + +The earlier attribution to the inner panel's `rot90` track was incorrect. +All then-known failing samples used that panel. +The apparently intact non-rotated sample had no touches. +`recording-overlay.swift` copies untouched raw `simctl` capture through without changes. +Thus, `--hide-touches` and an empty gesture list preserved capture. +The failure belonged to overlay export, not the panel. + +Synthetic capture measurements on this host identified the export preset as the cause. +Overlay burn-in fully re-encodes through `AVAssetExportSession`. +The default `medium` tier selected `AVAssetExportPresetMediumQuality`. +This fixed-canvas preset reduces the long edge to 480px. +It changes `1206x2622` to `220x480` and landscape capture to `480x220`. +Only `AVAssetExportPresetHighestQuality` preserves arbitrary geometry. +Hardware encoding makes full-resolution export inexpensive: approximately 1–2s for 90s at `1206x2622`. +Thus, `high` was not slower. + +`recording-overlay.swift` now always exports through the geometry-preserving preset at both quality tiers. +`--quality` no longer reduces capture resolution. +Before adoption, it verifies that the composited track's resolved size matches the capture. +It also rejects uniformly black output when the raw capture had visible content. +Either failure throws instead of publishing the output. +The caller drops the overlay, retains raw capture, and reports `overlayWarning` on `record stop`. +The same synthetic captures now match `--hide-touches`: | Overlay | Interactions | Exported size (before → after) | Black? | | --- | --- | --- | --- | @@ -370,198 +416,212 @@ the same synthetic captures re-measures the failing rows as matching `--hide-tou | default | taps + scroll, landscape source | `480x220` → source size | no | | `--hide-touches` | two taps | `1206x2622` → `1206x2622` | no | -The `after` column is this host's offline synthetic harness, which reproduces the collapse and -confirms the fix preserves geometry. The completion condition's on-device re-measure of these rows on -a non-rot90 target and on the Duo inner panel is carried by `test/integration/recording-overlay.test.ts`, -a device-lane case gated behind `AGENT_DEVICE_RECORDING_E2E` that compares a touched export to a -`--hide-touches` control, asserts the same track size, and asserts the overlay actually drew. - -The device-lane test reads each export's transform-applied size back through AVFoundation — -`test/integration/support/recording-inspect.swift` reports `renderWidth`/`renderHeight` from the same -`resolvedRenderSize` the overlay tool uses — so a `rot90` panel is compared upright rather than on its -sideways coding grid, and no second MP4 geometry parser is added to the shipped packages. - -A quarter-turn transform can leave the upright frame outside a zero-origin composition canvas. -The Duo inner capture has a `2006x2852` natural size and transform `[0, 1, -1, 0, 0, 0]`, which -places its upright `2852x2006` bounds at `x=-2852`. Applying that transform without translating the -bounds into the render canvas clips the source content and trips the compositor's all-black check -(#2774). The overlay now derives its render size and origin translation from the same transformed -bounds. Native export regressions cover an identity transform and both translated and -negative-origin quarter turns, asserting upright source content, unchanged geometry, and drawn -touches. +The `after` column comes from this host's offline synthetic harness. +It reproduces the failure and confirms preserved geometry after the fix. +`test/integration/recording-overlay.test.ts` carries the on-device completion measurements for a non-rot90 target and Duo inner panel. +`AGENT_DEVICE_RECORDING_E2E` enables this device-lane case. +It compares touched export with a `--hide-touches` control. +It asserts equal track size and verifies that the overlay drew touches. + +The device test reads transform-applied export dimensions through AVFoundation. +`test/integration/support/recording-inspect.swift` reports `renderWidth`/`renderHeight` through the overlay tool's same `resolvedRenderSize`. +Thus, `rot90` comparison uses upright geometry instead of the sideways encoded grid. +Shipped packages gain no second MP4 geometry parser. + +A quarter-turn transform can place the upright frame outside a zero-origin composition canvas. +Duo inner capture has natural size `2006x2852` and transform `[0, 1, -1, 0, 0, 0]`. +Its upright `2852x2006` bounds start at `x=-2852`. +Without translation into the render canvas, the transform clips content and triggers the all-black check (#2774). +The overlay derives render size and origin translation from the same transformed bounds. +Native export regressions cover identity, translated quarter turns, and negative-origin quarter turns. +They assert upright content, unchanged geometry, and drawn touches. ## Verified on a booted Duo -Closed pose: `screenshot` moved from `669x951` luma 0.09 to `466x678` luma 65.8; a tap on Safari's -address field at `(191, 620)` opened the keyboard; and on the `examples/test-app` dev build a -41-node `snapshot -i`, a tap that dismissed the dev-menu sheet at `(345, 301)`, and a Catalog-tab tap -at `(128, 626)` that settled `+19 -15`. - -Open pose, after an operator opened the device: the capture names `LCD-1` at `951x669 @1x`, -`snapshot -i` returns Safari's nodes on that surface, `tap @e4` resolves to `(590, 478)` inside it, -and text sent with `type` is found again by `find text` — hit testing and read-back both follow the -lit panel. A ref issued before the fold is refused afterwards as an expired frame rather than -replayed at the new point size, which is the pose-change rule working as designed. - -Half-open pose under the amended settle rule, on a booted iPhone Duo (iOS 27.1) at this PR's head: -`fold open` reported 180° on its end-stop reading, and `fold half-open` from that open pose reported -130° naming `LCD-1` at `669x951pt`. The hinge was travelling when the press landed and still came to -rest inside the interval, so requiring a settled pair did not lose the Book preset. A -`devicectl device motion hinge-angle` stream read afterwards reported `Angle:130,0° Velocity:+0,0°/s` -— the hinge is where the pair rule said it was. The run's session was closed and its daemon stopped -afterwards. +In the closed pose, `screenshot` changed from `669x951` at luma 0.09 to `466x678` at luma 65.8. +A Safari address-field tap at `(191, 620)` opened the keyboard. +The `examples/test-app` dev build produced a 41-node `snapshot -i`. +A tap at `(345, 301)` dismissed the dev-menu sheet. +A Catalog-tab tap at `(128, 626)` settled `+19 -15`. + +After an operator opened the device, capture explicitly named `LCD-1` at `951x669 @1x`. +`snapshot -i` returned Safari nodes on that surface. +`tap @e4` resolved inside it at `(590, 478)`. +`find text` found text sent through `type`. +Hit tests and readback therefore followed the lit panel. +A pre-fold ref was refused as an expired frame instead of replayed at the new point size. + +The amended settle rule was verified on a booted iPhone Duo with iOS 27.1 at that PR's head. +`fold open` reported an end-stop reading of 180°. +From that pose, `fold half-open` reported 130° and `LCD-1` at `669x951pt`. +The moving hinge settled inside the interval. +The two-reading requirement therefore preserved the Book preset. +A subsequent `devicectl device motion hinge-angle` stream printed `Angle:130,0° Velocity:+0,0°/s`. +This confirmed the settled pair's result. +The session closed and its daemon stopped after the run. ## Which XCTest capture surface sees the lit panel -#2727 measured every `XCUIScreenshotProviding` surface a runner can reach against the `simctl io -screenshot --display=` capture of the panel CoreDevice reports lit, on iPhone Duo (iOS 27.1, -Xcode 27.1 beta) closed at hinge 0° and open at 180°, with a 130° session on different app content -as the corroboration run and iPhone 17 (iOS 27.0) as the single-panel control. Poses came from the -hinge read-back, not from the pose that was requested — the amendment above is why that is the only -defensible source: the first two inner-panel sessions were booked as open and both read back 130°, -so they are recorded here as corroboration and the settled 180° session carries the table. A -different app screen was shown per session, so neither panel could be mistaken for the other by -content, and the app was left untouched between the oracle and the runner capture, so a luma gap is -a source difference rather than a content one. Mean luma comes from a 48x48 resample; panel -identity comes from CoreDevice's `displayId` and pixel agreement from comparing decoded pixels, so -no claim here rests on luma. Captured dimensions are PNG header dimensions, which is the panel's -native geometry — the capture's own orientation presents some of them turned. +#2727 compared every runner-reachable `XCUIScreenshotProviding` surface against `simctl io screenshot --display=` for CoreDevice's lit panel. +The targets were iPhone Duo with iOS 27.1 and Xcode 27.1 beta, closed at 0° and open at 180°. +A 130° session with different app content corroborated results. +An iPhone 17 with iOS 27.0 served as the single-panel control. +Hinge readback determined pose, not the requested preset. The amendment above makes hinge readback the only defensible source for these recorded poses. +Two initial inner-panel sessions requested open but read back 130°. +They therefore serve as corroboration; the settled 180° session supplies the table. + +Each session showed a different app screen to distinguish panel content. +The app remained unchanged between the reference and runner captures. +Thus, luma differences reflect sources rather than content changes. +Mean luma uses a 48x48 resample. +CoreDevice `displayId` supplies identity, and decoded-pixel comparison supplies agreement. +No claim here depends on luma. +PNG headers supply captured dimensions in native panel geometry. +Capture orientation presents some images turned. | Capture surface | Outer panel lit (closed) | Inner panel lit (open, 180°) | Verdict | | --- | --- | --- | --- | | `simctl` capture of the lit panel | `1398x2034`, luma 221.86 | `2853x2007`, luma 192.02 | reference | -| `XCUIScreen.main.screenshot()` | `1398x2034`, luma 221.90 | `1398x2034`, **luma 0.00** | Names the outer panel in every pose, so it is correct only while that panel is lit | -| `XCUIApplication.screenshot()` | `1398x2034`, luma 221.90 | `2006x2852`, **luma 0.00** | Inner geometry, outer content: follows `app.screen`, which stays pinned to panel `1` | -| `XCUIScreen.screens[i].screenshot()` | its own panel | its own panel | Correct per object; naming the panel is the caller's job, never a default | +| `XCUIScreen.main.screenshot()` | `1398x2034`, luma 221.90 | `1398x2034`, **luma 0.00** | Names the outer panel in every pose. Correct only when that panel is lit. | +| `XCUIApplication.screenshot()` | `1398x2034`, luma 221.90 | `2006x2852`, **luma 0.00** | Inner geometry, outer content. Follows `app.screen`, fixed to panel `1`. | +| `XCUIScreen.screens[i].screenshot()` | its own panel | its own panel | Correct per object. The caller must name the panel; there is no default. | | Resolved `window.screenshot()` | `1398x2034`, luma 221.90, 0 of 2,843,532 pixels differing from that panel's own screen capture | `2006x2852`, luma 192.03, 142 of 5,721,112 pixels differing by at most one per channel | **Captures the display the window is on in every pose** | -| `window.screenshot()` with no prior `frame` read | not run | identical to the row above, 0 pixels differing | The element resolves its own snapshot; the frame-first rule belongs to the identity read below, not to the capture | -| `element.screenshot()` | `566x168` crop of a `188.67x56` pt cell | `168x1061` crop of a `353.67x56` pt cell | Correct; the crop is taken in the panel's native geometry, so it is turned relative to the element's frame | - -The 130° corroboration session reproduced every verdict on the inner panel, including the black -`XCUIScreen.main`, the black application capture, and the window capture matching its panel's own -screen capture to within 187 pixels of 5,721,112 at content luma 236.91. So a window capture is the -same pixels as the screen capture of the panel its window sits on, and it is the only full-panel -surface measured that needed no pose knowledge. `element.screenshot()` followed the fold too, but -returns one element rather than a panel. - -Four facts the capture path cannot read off the image: - -- **Identity, and how little of it is declared.** `XCUIScreen.h` declares `screens` and - `mainScreen` alone, and documents `mainScreen` as "the primary screen of the device", so naming - the outer panel in every pose is the documented behavior and not a malfunction. `displayID`, - `scale`, and `bounds` answered KVC on every screen measured while `frame` and `name` raised - `NSUnknownKeyException`, but none of those keys appears in any XCTest header in this toolchain: - the identity read is as undocumented as the one #2724 already relies on for gesture routing, and - #2728 inherits that breakage risk whichever way it goes. It should reuse #2724's resolved-display - read rather than open a third route to the same private key. The ordering #2724 recorded held - here: `app.screen` and an unresolved window reported `displayID` `1` while the app was visibly on - panel `3`, and the resolved window reported `3`. `XCUIScreen` carries no panel-power fact and no - `displayWithID:` lookup, so a panel is chosen by filtering `XCUIScreen.screens` and - `backlightState` stays CoreDevice's. - **Geometry.** The capture carries the panel's own scale - (measured `3`, equal to that panel's CoreDevice `pointScale`) in the panel's *native* geometry - rather than the interface orientation: on the `rot90` inner panel the PNG is `2006x2852` with the - landscape content rotated inside it and the `UIImage` carrying capture orientation raw value `3`, - whose display size transposes the panel, where `simctl` exports the same panel `2853x2007` - upright. The runner's production encoder and `XCUIScreenshot`'s produce 0 differing pixels for - one capture from `358,402` and `328,181` encoded bytes, so the turn belongs to the capture and - not to an encoder. Rotating is not enough to compare the two: the raw canvas is `2006x2852` and - `simctl`'s `2853x2007`, a pixel apart on each axis. The shipped encoder draws the capture upright - at that image's own logical size and scale, which pays the rotation and takes no crop: the one-pixel - difference stays, because stretching it to CoreDevice's nominal panel box would invent pixels the - capture never held (#2741). - **Overlays.** A screen capture and a window capture are both display - captures — the second cropped to the window — so the software keyboard, a SpringBoard-hosted - permission alert, and the status bar all appear in either. It cuts the other way as well: that - alert's own window - resolved to `displayID` `3`, the dark panel in the closed pose, and capturing it yielded a black - `668x950` crop indistinguishable from any capture of a dark panel. Whether a system window on the - *lit* panel captures was not measured, so a system surface is verified on the app's panel, not - through its window. - **Unresolved window.** Asking an element that does not exist for screenshot - data raises `Element Window (First Match) cannot request screenshot data because it does not - exist` instead of capturing anything, but that is the element route only: `XCUIAutomation` also - contains `Could not resolve displayID for snapshot %@; falling back to main display (id %lld)`, - so an internal snapshot-to-display resolution failure can reach the main display silently. A - helper therefore resolves the window itself and reports its own typed reason, and does not treat - that message text as the contract. Defaulting to `XCUIScreen.main` on that path would be this - ADR's original bug, renamed. - -On the single-panel control every surface agreed: one screen, `1206x2622`, `rot0`, upright crops, no -rotation to repair. A window-resolved source changes nothing there except that its identity becomes -explicit. +| `window.screenshot()` with no prior `frame` read | not run | identical to the row above, 0 pixels differing | The element resolves its own snapshot. The frame-first rule applies only to the identity read below. | +| `element.screenshot()` | `566x168` crop of a `188.67x56` pt cell | `168x1061` crop of a `353.67x56` pt cell | Correct. The crop uses native panel geometry and is turned relative to the element frame. | + +The 130° session reproduced every inner-panel verdict. +`XCUIScreen.main` and application captures were black. +Window capture matched the panel screen capture within 187 pixels of 5,721,112 at content luma 236.91. +Window capture therefore produces the pixels of its window's display. +It is the only measured full-panel surface that required no pose knowledge. +`element.screenshot()` also followed the fold but returned only one element. + +The image cannot establish these four facts: + +- **Identity and declaration limits.** + `XCUIScreen.h` declares only `screens` and `mainScreen`. + It describes `mainScreen` as "the primary screen of the device". + Selection of the outer panel in every pose is therefore documented behavior. + On all measured screens, KVC answered `displayID`, `scale`, and `bounds`. + `frame` and `name` raised `NSUnknownKeyException`. + No XCTest header in this toolchain declares these keys. + This identity read has the same undocumented dependency as #2724's gesture routing. + #2728 inherits that breakage risk with either choice. + It should reuse #2724's resolved-display read instead of adding a third path to the private key. + The ordering from #2724 reproduced here. + `app.screen` and an unresolved window reported `displayID` `1` while the app visibly occupied panel `3`. + The resolved window reported `3`. + `XCUIScreen` supplies neither panel power nor a `displayWithID:` lookup. + Selection filters `XCUIScreen.screens`; CoreDevice retains `backlightState` authority. +- **Geometry.** + Capture reports its panel's scale, measured as `3`, equal to CoreDevice `pointScale`. + Geometry is native, not interface-oriented. + On the `rot90` inner panel, the PNG is `2006x2852` with landscape content rotated inside it. + The `UIImage` capture orientation has raw value `3`, whose display size transposes the panel. + `simctl` exports the same panel upright as `2853x2007`. + Production runner encoding and `XCUIScreenshot` encoding produce 0 different pixels from one capture, despite `358,402` versus `328,181` encoded bytes. + The rotation therefore belongs to capture, not encoding. + Rotation alone cannot equalize the images: raw `2006x2852` and `simctl` `2853x2007` differ by one pixel per axis. + The shipped encoder draws upright at the image's own logical size and scale, without a crop. + It preserves the one-pixel difference. + Stretching to nominal CoreDevice dimensions would invent pixels absent from capture (#2741). +- **Overlays.** + Screen capture and window capture both capture a display; window capture crops it to the window. + Both include the software keyboard, SpringBoard permission alerts, and status bar. + However, that alert's own window resolved to `displayID` `3`, the dark panel in the closed pose. + Its capture returned a black `668x950` crop, indistinguishable from other dark-panel captures. + The investigation did not measure a system window on the lit panel. + System-surface verification therefore uses the app's panel instead of the system window. +- **Unresolved window.** + A nonexistent element raises `Element Window (First Match) cannot request screenshot data because it does not exist` for screenshot requests. + This refusal belongs only to the element route. + `XCUIAutomation` also contains `Could not resolve displayID for snapshot %@; falling back to main display (id %lld)`. + An internal snapshot-to-display failure can therefore silently select the main display. + The helper resolves the window itself and reports its own typed reason. + It does not use that message text as a contract. + A default to `XCUIScreen.main` would recreate the original failure. + +Every surface agreed on the single-panel control: one screen, `1206x2622`, `rot0`, upright crops, and no rotation correction. +A window-resolved source changes only explicit identity there. ## Interaction display and viewport -On an unfolded Duo, `XCUIApplication.frame` reports the inner panel in native portrait -coordinates (669x951), while the app window and its controls report landscape coordinates -(951x669). The iOS capture viewport and synthesized gesture reference frame therefore come -from the resolved app window. Normalizing against the application frame rotates an already -oriented window; inverting that normalization alone does not produce native digitizer coordinates. -The measured inner-panel button row in `contracts/fixtures/window-coordinate-space.json` maps -window (476,226) to native digitizer (226,475). - -Synthesized gesture records use `initWithName:displayID:interfaceOrientation:` and the resolved -window's `screen.displayID`. Reading the window frame must precede reading its screen: an -unresolved element can still report the main screen. `app.screen` is not the display authority -for interactions on this device. Coordinate-based XCTest actions also originate at the resolved -window, so long presses and coordinate fallbacks carry its display identity. - -CoreDevice remains the capture-panel authority for host screenshots and recordings. Gesture -routing follows the window hosting the target app, without a hard-coded panel ID or cached pose. -Verification must observe a fresh app-visible outcome: event synthesis success and -`TouchEventsCompleted` alone do not prove a hit. On iOS 27.1, explicit inner-panel events reach -UIKit; the silent misses investigated here came from the default outer-display route and the -incorrect viewport, not an inner-panel delivery prohibition. +On an unfolded Duo, `XCUIApplication.frame` reports inner-panel native portrait coordinates, 669x951. +The app window and controls report landscape coordinates, 951x669. +The iOS capture viewport and synthesized gesture reference frame therefore use the resolved app window. +Normalization against the application frame rotates an already-oriented window. +Inversion of that normalization alone does not produce native digitizer coordinates. +The measured inner-panel button row in `contracts/fixtures/window-coordinate-space.json` maps window (476,226) to native digitizer (226,475). + +Synthesized gesture records use `initWithName:displayID:interfaceOrientation:` and the resolved window's `screen.displayID`. +The window frame read must precede its screen read. +An unresolved element can still report the main screen. +`app.screen` is not authoritative for interaction display on this device. +Coordinate-based XCTest actions also originate at the resolved window. +Long presses and coordinate fallbacks therefore retain its display identity. + +CoreDevice remains authoritative for host screenshot and recording panels. +Gesture routes follow the target app's window without a fixed panel ID or cached pose. +Verification must observe a fresh app-visible outcome. +Event synthesis success and `TouchEventsCompleted` alone do not prove a hit. +On iOS 27.1, explicit inner-panel events reach UIKit. +The silent misses investigated here came from default outer-display routes and incorrect viewports, not a prohibition on inner-panel delivery. ## Accepted evidence gaps -- **Runner capture on a physical foldable.** The runner resolves the app's own display and was - verified across the open, half-open, and closed poses on a simulated Duo, but the same route on a - physical foldable was never exercised. -- **Runner observation paths that sample a frame.** Keyboard settling, screen recording, and the - navigation fallback now take their frame from the display owning a window and each states in - `runner.log` what it looked at. Keyboard settling and the navigation fallback were each watched - once while serving a lit panel — `AGENT_DEVICE_RUNNER_KEYBOARD_STABILITY samples=3 captured=3 - settled=yes` behind a `returnKeyType="done"` field, and - `AGENT_DEVICE_RUNNER_IN_APP_BACK_VISUAL_VERIFICATION ... changed=no` as the no-change control - (#2741) — and neither was watched while the pose changed underneath it. Screen recording was never - watched: a pose change mid-recording still changes the captured display under a writer sized from - the first frame, and that mismatch was never exercised. -- **Desktop capture paths.** The two `#if os(macOS)` siblings of the `screenshot` capture in - `RunnerTests+CommandExecution.swift` keep capturing the desktop screen, which also reaches the - screen through Screen Capture Kit in `AgentDeviceMacOSHelper`; only the iOS branches changed - target, and the desktop behavior stays unmeasured here. - -- **Quarter-turn detection.** Both Duo panels report - `currentOrientation: rot90`, and no available path rotates a foldable, so the orientation half of - the inventory is carried but never exercised against a changed value. - **Pose control on a - second Device Hub instance.** Discovery checks every matching process, but simultaneous Xcodes - and the secondary-display fold matrix remain unverified on live hardware. - **A hinge that - settles slowly.** Four reads is twenty seconds of streams, and a Duo that needs longer to come to - rest inside `half-open` is now refused where the superseded rule would have reported a pose. - Every Duo run observed for this change settled inside the budget; no simulator that needs longer - was seen, so the budget stays as it is rather than growing on a hypothesis. - -- **Pose control when two simulators share a name.** The earlier title-only fallback could press - a different Duo when the intended device's row was unavailable; hinge verification refused the - result only after the other device had moved. Discovery now requires the UDID row, and selection - confirms `AXSelected` as well as the window title before pressing. Live wrong-UDID refusal and - hidden-sidebar recovery were checked; the simultaneous same-name simulator matrix remains open. -- **Physical foldables.** Device Hub poses simulators only; the leaf fact refuses a physical device, - and the hinge stream on one was not exercised. +- **Runner capture on a physical foldable.** + The app-display route was verified on simulated Duo open, half-open, and closed poses. + The same route remains untested on physical foldables. +- **Runner frame observations.** + Keyboard settling, screen recording, and navigation fallback now sample the display that owns a window. + Each records its observation in `runner.log`. + Keyboard settling was observed once on a lit panel behind a `returnKeyType="done"` field: + `AGENT_DEVICE_RUNNER_KEYBOARD_STABILITY samples=3 captured=3 settled=yes`. + Navigation fallback was observed once on a lit panel as the no-change control (#2741): + `AGENT_DEVICE_RUNNER_IN_APP_BACK_VISUAL_VERIFICATION ... changed=no`. + Neither was observed during a pose change. + Screen recording was never observed. + A mid-recording pose change moves capture to another display under a writer sized from the first frame. + This mismatch remains untested. +- **Desktop capture paths.** + Two `#if os(macOS)` screenshot siblings in `RunnerTests+CommandExecution.swift` still capture the desktop screen. + `AgentDeviceMacOSHelper` also reaches it through Screen Capture Kit. + Only iOS branches changed target. + Desktop behavior remains unmeasured here. +- **Quarter-turn detection.** + Both Duo panels report `currentOrientation: rot90`. + No available path rotates a foldable. + Inventory therefore carries orientation without a test against changed orientation. +- **A slowly settled hinge.** + Four reads consume twenty seconds of streams. + A Duo that takes longer to settle inside `half-open` is refused. + The former rule would have reported pose. + Every Duo run observed for this change settled within budget. + No slower simulator was observed, so the budget remains unchanged rather than increasing for an unobserved case. +- **Physical foldables.** + The leaf fact refuses a physical device. + The hinge stream was never exercised on a physical device. ## Android emulators (amendment, 2026-10-10) -The same `fold` command poses a foldable Android emulator, with the two facts of this ADR kept -apart in the same way. There is no host display authority to consult: the emulator console's -`posture ` moves the hinge sensor to the angle the AVD profile defines for the posture (0°, 90°, -180° on the Pixel folds; 15°, 90°, 165° on the generic "7.6in Foldable"), and the pose is verified -by polling `cmd device_state print-state` until the guest commits the posture's own device state -(`CLOSED`, `HALF_OPENED`, `OPENED`), which is what `WindowManager`'s `FoldingFeature` derives from; -the hinge sensor is read back for the reported angle, not judged. A `half-open` Android pose is -therefore 90° on every profile seen so far, where the Duo's is 130°. - -Folding to the cover display can raise the keyguard (Android's "continue using apps on fold" -setting, which emulator images do not honour); `fold` dismisses it, because every later capture -would otherwise read the lock screen instead of the app. A profile that lists none of those device -states has no hinge and is refused as `single-panel-device`, the typed reason a single-panel -simulator gets. Keyframes and panel geometry stay Apple-only: the console poses only fixed -postures, and Android reports the app window on its next snapshot rather than a panel. +The same `fold` command poses a foldable Android emulator. +It keeps the two facts of this ADR separate in the same way: which panel a capture must name and what pose the device is in. +There is no host display authority. +Emulator-console `posture ` sets the hinge sensor to the profile's angle: + +- Pixel folds: 0°, 90°, 180°. +- Generic "7.6in Foldable": 15°, 90°, 165°. + +Verification polls `cmd device_state print-state` until the guest commits the posture's `CLOSED`, `HALF_OPENED`, or `OPENED` state. +`WindowManager` derives `FoldingFeature` from this state. +Hinge-sensor readback supplies the reported angle; it does not determine success. +All observed Android profiles use 90° for half-open; Duo uses 130°. + +A fold to the cover display can show keyguard. +Emulator images do not honor Android's "continue using apps on fold" setting. +`fold` dismisses keyguard so later captures show the app instead of the lock screen. +A profile that lists none of these device states has no hinge. +It is refused as `single-panel-device`, the same typed reason as a single-panel simulator. +Keyframes and panel geometry remain Apple-only. +The emulator console supports only fixed postures. +Android reports the app window on the next snapshot instead of a panel. diff --git a/docs/adr/0026-scroll-clip-authority.md b/docs/adr/0026-scroll-clip-authority.md index e8c2916534..b70547701b 100644 --- a/docs/adr/0026-scroll-clip-authority.md +++ b/docs/adr/0026-scroll-clip-authority.md @@ -2,139 +2,189 @@ ## Status -Accepted (2026-09-22). The ownership decision is shipped: #2758 reads an indicator's owner off the -parent edge, climbing only same-frame ancestors — the tolerance the #2754 step 3 survey justified on -real captures — and #2759 pairs the two eligibility lists and settles `ScrollArea` for macOS. Two -#2754 steps stay open work rather than part of this decision: the runner-stage differential arm, and -the typed ejection disposition the "every removed source" row below names. Refines ADR 0004's -fact/interpretation boundary for scroll geometry; does not supersede it. - -An interactive iOS snapshot of a list dropped every row after a row holding selectable text. A -`UITextView` is a UIScrollView and XCTest publishes its scroll indicator inside the text, so the -indicator was attributed to the surrounding list, the list's visible band became one 22 px line, and -everything below it was clipped out of `snapshot -i` (#2214 in 0.21.0, patched for that one type by -#2740). The root cause is not the unrecognised type — it is that presentation **inferred** an owner by -walking up past the indicator's parent, and that guess decided which nodes existed in the output. +Accepted (2026-09-22). The ownership decision is shipped. +#2758 reads indicator ownership from the parent edge and traverses only same-frame ancestors. +The #2754 step 3 survey justified this tolerance with real captures. +#2759 pairs the eligibility lists and decides macOS `ScrollArea` behavior. +Two #2754 steps remain open, outside the shipped decision: + +- The runner-stage differential arm. +- The typed ejection disposition in the "every removed source" rule below. + +This ADR refines ADR 0004's fact/interpretation boundary for scroll geometry. +It does not supersede ADR 0004. + +An interactive iOS list snapshot lost all rows after a row with selectable text. +A `UITextView` is a UIScrollView. +XCTest publishes its scroll indicator inside the text. +Presentation attributed that indicator to the surrounding list and reduced the visible list band to one 22 px line. +It clipped every lower row from `snapshot -i` (#2214 in 0.21.0). +#2740 patched this one type. +The cause was an **inferred** owner from traversal beyond the indicator's parent, not an unrecognized type. +That inference determined output membership. ## Rules at a glance | Situation | Behavior | | --- | --- | -| Which scroll view an indicator reports on | Its **parent**, when that parent is a scroll type. UIKit publishes an indicator inside its own scroll view, and the producer's tree already says so. Ownership also passes **up through ancestors that share their own parent's exact frame** — the producer's transparent wrappers around one scroll region (Safari nests `Other` → `WebView` → `WebView` above a `WKWebView`'s `ScrollView`, all one frame). A node that is **itself** a scroll type and carries the label describes itself, not its parent, so it owns nothing (resolved: #2754 step 1 — banding its parent would clip a sibling list to the host's band, mirroring #2214 upward) | -| An indicator whose nearest scroll-typed ancestor is reached only across a frame change — a smaller `TextView`/`WebView`/cell host | Owns nothing. No band, no clip, and the host's own scrollability stays with that host. The frame change, not a label or type, is what ends the walk (resolved: #2754 step 3 on a real `WKWebView` capture) | -| The visible band | Derived by presentation from the owner's frame and the indicator's track. Never reported by a producer | -| Removing a node **because a scroll band hides it** | Requires evidence: the clip fold's result, or a band whose owner is the indicator's parent. A guess about ownership may not do it | -| Removing a node by semantic delegation — a collapsed row, a duplicate label, a wrapper's content | Out of scope here. That is compaction, and it keeps its own authority | -| Weaker evidence than a parent edge | Directional hints (`hiddenContentAbove` / `hiddenContentBelow`) and nothing that changes membership | -| Every removed source | Must end with a typed disposition — the open #2754 step 5 contract, not shipped code. Internal evidence, never wire vocabulary | +| Indicator ownership | Its **parent** owns it when the parent is a scroll type. Ownership also passes through ancestors with their own parent's **exact frame**. These are transparent wrappers around one region. A scroll-typed node with the indicator label describes itself and owns nothing. | +| An indicator whose nearest scroll-typed ancestor is reached only across a frame change, for example a smaller `TextView`/`WebView`/cell host | The indicator owns nothing. It gets no band and no clip. The smaller `TextView`/`WebView`/cell host retains its scrollability. A frame change ends traversal, regardless of label or type. | +| Visible band | Presentation derives it from the owner's frame and indicator track. A producer never reports it. | +| Removal **because a scroll band hides a node** | Require the clip fold's result or a band owned by the indicator's parent. Inferred ownership cannot authorize removal. | +| Removal of a node by semantic delegation, for example a collapsed row, a duplicate label, or wrapper content | This is compaction, outside this ADR. It retains its own authority. | +| Evidence weaker than a parent edge | Emit only directional hints, `hiddenContentAbove` / `hiddenContentBelow`. Do not change membership. | +| Every removed source | Must have a typed disposition. This is the open #2754 step 5 contract, not shipped code. It is internal evidence, never wire vocabulary. | ## Contracts -**Ownership is read, not inferred.** A scroll view's indicators arrive as its children, so the parent -edge is the producer's own claim and no new contract field, cross-language ownership table, or -capture-local index remapping is needed. That edge survives to presentation on both input stages -because every scroll host iOS can emit is regular-eligible, so projection re-parents an indicator only to -that host: `REGULAR_ELIGIBLE_TYPES` in `ios-snapshot-engine/projection.ts` and `eligibleInteractiveTypes` -in `SnapshotPresentationProjection.swift` both carry `Cell`, `CollectionView`, `ScrollView`, `Table`, -`TextView`, and `WebView`. Those two lists are one fact in two languages and must stay in step. - -Passing ownership up through **same-frame** ancestors keeps ownership read rather than inferred: the -producer states two nodes are the same rectangle, which is a structural fact, not a label or type guess. -It is needed because a `WKWebView`'s page indicator is published under `WebView` wrappers that fill the -`ScrollView` exactly (#1784/#1797, and real captures in #2754 step 3); strict parent-edge ownership left -that scroller unbanded and returned content scrolled under the toolbar. The walk stops at the first frame -change, so a host that is smaller than its parent — a `WebView` row in a list — is a different scroll -region and still owns nothing, which is the #2214 bug the pass-through must not reintroduce. - -That eligibility claim is scoped to iOS, and `scrollarea` is why. `isScrollableSnapshotType` accepts it, -neither eligible set contains it, and the iOS runner never emits it — it originates in the macOS helper's -`AXScrollArea` mapping. That helper's trees do reach these rules, through `snapshot-desktop-surface.ts` -→ `ios-snapshot-runtime.ts` → `publishIosSnapshot`, so on that surface a `ScrollArea` host can be dropped -by eligibility while its children re-parent past it, which leaves a parent-edge lookup with no owner. -Reusing this rule for macOS therefore needed a `ScrollArea` eligibility decision made there, not -carried over from the iOS claim above, and #2759 made it with cases rather than by admitting the -type for iOS's sake: `scrollarea` stays out of both eligible sets, a `ScrollArea` carrying content -survives eligibility on those trees and owns its band, and a label-less one is dropped, leaving its -indicator with no owner to band — which under-clips (safe) instead of mis-clipping the enclosing -list (`eligibility-parity.test.ts`). - -**Why the band exists at all.** XCTest reports a scroll view's frame spanning the bars and the safe -area, not the visible track. In the pinned Settings tree the `CollectionView` frame is the whole screen, -0–874, while its indicator track is 116–812 (`runner-presentation.test.ts`). The frame alone therefore -cannot express visibility, and deleting indicator clipping would return content that is scrolled under -the chrome. The band is necessary; only *who owns it* was ever in question. - -**Visibility ejection needs evidence, and every ejection must be recorded.** Most of the engine's 23 -removal sites delegate semantics or drop decoration — a collapsed row, a duplicate label, a -wrapper's content, a system indicator — and this ADR does not touch their authority. It governs the -four visibility-driven sites among them, the scroll band's one and the keyboard band's three, -alongside the clip fold's own inclusion decision in `geometry-policy.ts`. Of those, the scroll band -is the one a weak signal can reach, and directional hints are its safe outlet when nothing is the -indicator's parent. Every source index must end either presented, with its representatives, or -removed with a typed reason, which makes the ledger complete by construction rather than gated; -#2754 step 5 still owes that reason, and `presentedIndexesBySourceIndex` in -`ios-snapshot-engine/semantic-index.ts` records representatives and no removal reason today. That -ledger is the shape to extend; a second parallel ledger would be a second source of truth. +**Read ownership from structure. Do not infer it.** +A scroll view's indicators are its children. +The parent edge is therefore the producer's ownership claim. +No new contract field, cross-language ownership table, or capture-local index remap is necessary. +The edge survives until presentation at both input stages because every scroll host that iOS can emit is regular-eligible. +Projection therefore re-parents indicators only to that host. +These two lists encode the same fact and must remain synchronized: + +- `REGULAR_ELIGIBLE_TYPES` in `ios-snapshot-engine/projection.ts`. +- `eligibleInteractiveTypes` in `SnapshotPresentationProjection.swift`. + +Both contain these types: + +- `Cell` +- `CollectionView` +- `ScrollView` +- `Table` +- `TextView` +- `WebView` + +**Same-frame pass-through preserves structural ownership.** +The producer states that two nodes have the same rectangle. +This is a structural fact, not a label or type inference. +Same-frame pass-through is necessary because a `WKWebView` page indicator is published under `WebView` wrappers that exactly fill its `ScrollView`. +#1784/#1797 and real #2754 step 3 captures establish this shape. +Strict parent-edge ownership left this region without a band and exposed content under the toolbar. +Traversal stops at the first frame change. +A smaller host, such as a `WebView` row inside a list, describes a different scroll region and still owns nothing. +Same-frame pass-through must not restore #2214's failure. + +**The eligibility claim applies only to iOS.** +`isScrollableSnapshotType` accepts `scrollarea`, but neither eligible set contains it. +The iOS runner never emits it. +It originates in the macOS helper's `AXScrollArea` mapping. +Those trees reach these rules through `snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`. +On those trees, eligibility can remove a `ScrollArea` host while its children re-parent beyond it. +Then a parent-edge lookup finds no owner. +Reuse on macOS therefore required a separate eligibility decision. +#2759 established these cases without admitting the type for iOS: + +- `scrollarea` remains outside both eligible sets. +- A content-bearing `ScrollArea` survives eligibility and owns its band. +- A label-less `ScrollArea` disappears and leaves its indicator without an owner. + +The last case safely under-clips instead of incorrectly clipping the enclosing list. +`eligibility-parity.test.ts` proves these cases. + +**Why the band is necessary.** +XCTest reports a scroll frame across bars and safe areas, beyond the visible track. +In the pinned Settings tree, `CollectionView` spans the complete screen, 0–874, but its indicator track spans 116–812. +`runner-presentation.test.ts` records this example. +The frame alone cannot express visibility. +Removal of indicator clipping would expose content under system chrome. +The band is necessary; its owner was the disputed fact. + +**Visibility ejection requires evidence. Each ejection must be recorded.** +Most of the engine's 23 removal sites delegate semantics or remove decoration, for example: + +- A collapsed row. +- A duplicate label. +- Wrapper content. +- A system indicator. + +This ADR does not change their authority. +It governs four visibility-driven sites: one scroll-band site and three keyboard-band sites. +It also covers the clip fold's inclusion decision in `geometry-policy.ts`. +A weak signal can reach only the scroll-band site. +If nothing is the indicator's parent, directional hints are the safe outlet for that signal. + +Each source index must end presented with its representatives or removed with a typed reason. +This rule makes the ledger complete by construction, without a gate. +#2754 step 5 still owes the removal reason. +Currently, `presentedIndexesBySourceIndex` in `ios-snapshot-engine/semantic-index.ts` records representatives without removal reasons. +That ledger is the shape to extend. A second parallel ledger would be a second source of truth. ## Refuted alternatives -- **A producer-reported ownership field on `IosRunnerPresentation`.** The parent edge already carries the - fact, so a field would add validation, capture-local indexing, and remapping hazards of its own for - nothing. -- **Producer-computed visible bands.** Moves interpretation back across the process boundary ADR 0004 - removed it from. The band stays host-side. -- **An owner found by walking ancestors.** That walk is the incident. Skipping only the types known to - scroll — #2740's fix — leaves the next scroll-shaped host (`WebView`, a map view, a paged cell) to - reopen it. -- **One universal "is a scroll container" set, or a capability matrix of ownership/clip/termination - booleans.** Five sites answer variants of this question today (`ios-snapshot-engine/tree.ts`, - `geometry-policy.ts`, `invariants.ts`, `SnapshotVisibilityFold.swift`, `RunnerTests+Snapshot.swift`) - and their contents already differ: only `tree.ts` accepts `scrollarea`, the type the macOS helper emits - and the iOS runner never does. Characterise each site before reshaping any of them; with parent-edge - ownership the shape this rule needs is the existing `isScrollableSnapshotType`. -- **A rule-graph or effect system, and a two-API reshape/eject split.** Rejected on the shape of the - code. Nineteen of the 23 `suppressNode` sites legitimately eject, so handing a membership API to "one - clip rule" restores the convention it claims to enforce. Two functions already rewrite rects *and* eject - in one pass — the scroll rule and the navigation-bar affordance rule in `transitions.ts` — while - `noise-overlay` shows the split done properly: its presentation pass receives replacements only and a - separate suppression pass receives the ejecting surface. And a reshape surface that still accepts `rect` - would permit the exact rewrite that caused this incident. Inventory the sites in #2754 first; redesign - only what the inventory justifies. -- **A runtime guard on shrinkage instead of the differential.** A threshold would either fire on ordinary - compaction — the Settings tree legitimately drops rows under the chrome — or sit loose enough to wave - through a 74 → 20 collapse. The differential already asserts interactive ⊆ regular ⊆ raw; what it lacks - is coverage of the runner stage, since `assertProjectionSubsets` - (`ios-snapshot-engine/engine-properties.test.ts`) builds acquired inputs only and the Swift differential drops - interactive cases. Extending it to `stage: 'presented'` is what makes any future shrinkage visible. +- **A producer-reported ownership field on `IosRunnerPresentation`.** + The parent edge already supplies the fact. + A field would add validation, capture-local indexing, and remap hazards, and give no new information. +- **Producer-computed visible bands.** + This moves interpretation back across the process boundary. + ADR 0004 removed interpretation from the producer side of that boundary. + Bands remain host-side. +- **An owner found by walking ancestors.** + That walk caused the incident. + Exclusion of only known scroll types, as in #2740, leaves the next scroll-shaped host, for example a `WebView`, a map view, or a paged cell, to reopen the incident. +- **One universal scroll-container set or a matrix of ownership/clip/termination booleans.** + Five sites currently answer related questions: + - `ios-snapshot-engine/tree.ts` + - `geometry-policy.ts` + - `invariants.ts` + - `SnapshotVisibilityFold.swift` + - `RunnerTests+Snapshot.swift` + Their contents differ. + Only `tree.ts` accepts macOS `scrollarea`, which the iOS runner never emits. + Before you reshape any of these sites, characterize each site. + Parent-edge ownership needs only the existing `isScrollableSnapshotType`. +- **A rule graph, effect system, or two-API reshape/eject split.** + The existing code does not justify this structure. + Nineteen of 23 `suppressNode` sites legitimately eject nodes. + Membership access for "one clip rule" would restore the convention that the API claims to enforce. + The scroll rule and navigation-bar affordance rule in `transitions.ts` already change rectangles and eject nodes in one pass. + `noise-overlay` demonstrates a valid split: replacements-only presentation and a separate suppression pass with ejection authority. + A reshape API that still accepts `rect` would permit the original faulty change. + First inventory sites in #2754. + Redesign only what that inventory justifies. +- **A shrinkage guard instead of the differential.** + A threshold would either reject legitimate compaction or permit a 74 → 20 collapse. + Settings legitimately removes rows under system chrome. + The differential already asserts interactive ⊆ regular ⊆ raw. + It lacks runner-stage coverage. + `assertProjectionSubsets` in `ios-snapshot-engine/engine-properties.test.ts` constructs only acquired inputs. + The Swift differential omits interactive cases. + Extension to `stage: 'presented'` makes future shrinkage visible. ## Consequences and open evidence -- **Under-clipping was the open risk; same-frame pass-through resolves the real instance.** Strict - parent-edge ownership left a `WKWebView` page scroller unbanded because the page indicator is published - under `WebView` wrappers, leaking content under the toolbar (#1797, with #1784 adjacent). #2754 step 3 - surveyed real captures (Settings, Settings › Privacy & Security, a Safari `WKWebView` page — 25 indicators, - 18 under a non-scroll parent) and found exactly this shape on the web surface. Resolved by passing - ownership through same-frame ancestors, which reproduces `main`'s clip on all three real captures while - the smaller-host row cases (#2214) still own nothing. A genuinely frame-mismatched non-scroll wrapper - would still under-clip, but no captured iOS tree has that shape. -- **The `WebView` shape is now confirmed on real data.** A Safari `snapshot -i --raw` capture shows the - `WKWebView`'s indicator published under `Other` → `WebView` → `WebView` ancestors that fill the - `ScrollView` exactly; the fixture in `runner-presentation.test.ts` reduces that live tree. -- **The self case is resolved: a scroll-typed indicator owns nothing.** #2754 step 1 deleted the - self-ownership branch. Reading a scroll-typed node labelled as an indicator onto its own band is a - no-op (the indicator and container are the same rect, which `deriveScrollableViewportRect` refuses), - while reading it onto its parent clips a sibling list to that host — the #2214 failure mirrored - upward. So `findScrollIndicatorContainer` returns null when the node is itself a scroll type; - `runner-presentation.test.ts` pins a scroll host labelled as an indicator leaving its parent list's - band intact. -- **macOS is already a second consumer of these rules, and #2759 settled its `ScrollArea` - question.** Desktop capture runs the engine through `snapshot-desktop-surface.ts` → - `ios-snapshot-runtime.ts` → `publishIosSnapshot`, and its trees carry `ScrollArea`, which neither - eligible set admits: a content-bearing one survives eligibility and owns its band; a label-less - one is dropped and leaves its indicator with no owner, under-clipping instead of mis-clipping the - list. `eligibility-parity.test.ts` pins both outcomes and the TS/Swift eligibility pair. -- **Ejection inventory precedes any API change.** 23 suppression sites across ten rules, and two of them - (`scroll`, `transitions`) already rewrite rects on other nodes in the same pass that ejects — the - combination the proposed API split claimed to make impossible. -- **The 74 → 67 node delta on the reporting screen is unexplained.** No captured artifact exists, so - neither number is an acceptance baseline. +- **Under-clipping was the open risk. Same-frame pass-through resolves the real instance.** + Strict parent-edge ownership left a `WKWebView` page without a band because `WebView` wrappers contained the indicator. + It exposed content under the toolbar (#1797, with adjacent #1784). + #2754 step 3 surveyed Settings, Settings › Privacy & Security, and a Safari `WKWebView` page. + The captures contained 25 indicators, 18 under non-scroll parents. + The survey found exactly this shape on the web surface. + Same-frame pass-through reproduces `main` clipping on all three captures. + Smaller-host cases from #2214 still own nothing. + A non-scroll wrapper with a different frame would still under-clip. + No captured iOS tree has that shape. +- **Real data confirms the `WebView` shape.** + A Safari `snapshot -i --raw` capture shows the `WKWebView` indicator under `Other` → `WebView` → `WebView`. + These ancestors exactly fill the `ScrollView`. + `runner-presentation.test.ts` reduces that live tree into a fixture. +- **A scroll-typed indicator owns nothing.** + #2754 step 1 removed self-ownership. + A self-band is ineffective because indicator and container share a rectangle, which `deriveScrollableViewportRect` refuses. + A parent band would incorrectly clip a sibling list, reproducing #2214 in the opposite direction. + Thus, `findScrollIndicatorContainer` returns null for an indicator node that is itself a scroll type. + `runner-presentation.test.ts` pins that a scroll host labelled as an indicator leaves the parent list's band intact. +- **macOS already consumes these rules.** + Desktop capture uses `snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`. + #2759 decided the `ScrollArea` cases described above. + Content-bearing hosts survive and own their bands. + Label-less hosts disappear and leave ownerless indicators, which under-clip rather than incorrectly clip the list. + `eligibility-parity.test.ts` pins both outcomes and the TypeScript/Swift eligibility pair. +- **The ejection inventory precedes API changes.** + There are 23 suppression sites across ten rules. + Two, `scroll` and `transitions`, already change other nodes' rectangles and eject in the same pass. + The proposed API split claimed to prohibit this existing combination. +- **The reporting screen's 74 → 67 node change remains unexplained.** + No captured artifact exists. + Neither number is an acceptance baseline. diff --git a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md index d8a282e42a..75286abbd1 100644 --- a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md +++ b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md @@ -2,59 +2,66 @@ ## Status -Accepted (2026-10-07, #2469). The conflict below is resolved in favor of allowing -**byte-neutral (statement-neutral) re-homing**: the eager-closure budget's NO-GROWTH rule now -tolerates exactly the split shape — the head closure still evaluates every merge-base module, -every module it newly evaluates is new to the merge-base tree and to its deleted sources (a -`git mv` inside the base closure stays the same module through rename canonicalization, which -preserves modules already in the closure and is no pass for a pre-existing external module -moved in), and the closure's total module-scope statement count does not grow. The rule lives -in `scripts/__tests__/closure-growth-rule.ts`, planted in every failing direction by -`scripts/__tests__/closure-growth-rule.test.ts`. The hard ADR-0019 checks (façade exactness, no -platform implementation before binding) are untouched: the tolerance relaxes the module-count -proxy, never the loading property. +Accepted (2026-10-07, #2469). +The decision permits **byte-neutral (statement-neutral) re-homing** under the exact split conditions below. +The eager-closure NO-GROWTH rule tolerates this structure: + +- The head closure still evaluates every merge-base module. +- Every newly evaluated module is new to the merge-base tree and its deleted sources. +- Total module-scope statement count does not grow. + +Rename canonicalization preserves a `git mv` of a module already inside the base closure. +It does not exempt a pre-existing external module moved into the closure. +`scripts/__tests__/closure-growth-rule.ts` owns the rule. +`scripts/__tests__/closure-growth-rule.test.ts` plants violations in every failing direction. +The hard ADR-0019 checks remain unchanged: façade exactness and no platform implementation before binding. +The tolerance relaxes the module-count proxy, never the loading property. + +## Rules at a glance, resolved + +- A hub split is permitted when it is statement-neutral: it has containment, added-module novelty, and flat module-scope statement count. + The gate enforces all three. + Unexplained count growth still fails without an approval path. +- Do not add an approved-growth row, baseline entry, or suppression to pass the gate. + That would be the allowlist prohibited by `AGENTS.md`. + The split tolerance is a rule, not a waiver. + It decides from the two graphs, and nobody enters a value by hand for a split. +- Do not make registry construction asynchronous to bypass the walker. + ADR 0008 requires the synchronous root for compile-time totality. +- The split tolerance guarantees flat statement weight over a preserved closure. + It does not license additional eager edges beneath a façade. + The head must still evaluate each merge-base module. + The ADR-0019 checks remain mandatory. ## The conflict -[ADR 0008](0008-command-descriptor-registry.md) makes `packages/command-registry/src/registry.ts` -the single **synchronous** descriptor root. `RAW_COMMAND_DESCRIPTORS` and every derived view — the -`Command` literal union, the catalog name sets, the `name`-keyed policy maps, batch policy — are -module-scope values, and consumers reach them through **value** imports. +[ADR 0008](0008-command-descriptor-registry.md) establishes `packages/command-registry/src/registry.ts` as the single **synchronous** descriptor root. +`RAW_COMMAND_DESCRIPTORS` and all derived views are module-scope values: -`AGENTS.md` states that files past 1,000 lines are architecture debt and must be split before adding -behavior. The descriptor root is past that line. +- The `Command` literal union. +- Catalog name sets. +- `name`-keyed policy maps. +- Batch policy. -`scripts/__tests__/eager-closure-budgets.ts` (the ADR 0019 loading-shape probe) measures each -published entry surface's eager **module count** against the committed merge-base and forbids any -increase. It has no approval path for growth by design; `APPROVED_OVER_CEILING` applies only to -first-introduced entries against `NEW_ENTRY_CEILINGS`. +Consumers reach these values through **value** imports. -Splitting a hub module necessarily adds static value edges beneath it, so every entry surface that -reaches it grows by the number of new modules. The two rules have no shared approval path: one says -the file must be divided, the other says the division must not be visible to any importer. +`AGENTS.md` states that files beyond 1,000 lines are architecture debt and must split before new behavior. +The descriptor root exceeds that limit. -## Rules at a glance, resolved +The ADR 0019 loading-shape probe, `scripts/__tests__/eager-closure-budgets.ts`, measures each published entry surface's eager **module count** against the committed merge-base. +It prohibits any increase and deliberately has no approval path for growth. +`APPROVED_OVER_CEILING` applies only to newly introduced entries against `NEW_ENTRY_CEILINGS`. -- A hub split is allowed when it is statement-neutral: every merge-base module stays in every - affected closure, every newly evaluated module is new to the merge-base tree and to its - deleted sources (rename canonicalization preserves modules already in the base closure — it - does not make a moved-in external module novel), and total module-scope statements do not - grow. The gate enforces this; an unexplained count growth still fails with no approval path, - as before. -- Do not add an approved-growth row, a baseline entry, or a suppression to pass the gate. That is - the allowlist `AGENTS.md` forbids; the split tolerance is a rule, not a waiver — it decides from - the two graphs, and nothing is hand-entered per split. -- Do not make registry construction asynchronous to dodge the walker. ADR 0008's synchronous root is - load-bearing for compile-time totality. -- The split tolerance guarantees flat statement weight over a preserved closure; it does not - license adding eager *edges* under a façade — the head must still evaluate every merge-base - module, and the ADR-0019 checks stay hard. +A hub split necessarily adds static value edges. +Every entry surface that reaches the hub gains the new modules. +These rules have no shared approval path. +One requires a file split; the other prohibits the resulting increase for every importer. ## Measured -Splitting `RAW_COMMAND_DESCRIPTORS` into eleven family modules plus a shared trait module, byte-faithful -(80 descriptors, 79 byte-identical including comments), grew **every** entry surface reaching the root -by exactly **+11** modules. Route for all rows: +A byte-faithful split moved `RAW_COMMAND_DESCRIPTORS` into eleven family modules and one shared trait module. +It preserved 79 of 80 descriptors byte-for-byte, including comments. +Every entry surface that reaches the root grew by exactly **+11** modules through this route: `… → catalog.ts → registry.ts → descriptor-traits.ts + descriptors/*.ts`. | entry surface | merge-base | split | @@ -71,107 +78,123 @@ by exactly **+11** modules. Route for all rows: | `packages/command-registry/src/owner-files.ts` | 73 | 84 | | `packages/command-registry/src/registry.ts` | 72 | 83 | -Two facts make this unavoidable rather than incidental: +Two facts make the increase unavoidable: -- Extracting only the shared trait module — the smallest possible decomposition — costs **+1** on the - same eleven surfaces. There is no decomposition of this hub that passes. -- The walker erases type-only edges, so narrowing consumer imports cannot absorb the cost. These - consumers need the derived values, not the types. +- Extraction of only the shared trait module, the smallest decomposition, adds **+1** on the same eleven surfaces. + No decomposition of this hub passes the original rule. +- The walker excludes type-only edges. + Narrower consumer imports cannot offset the increase because consumers need derived values, not types. -The cost is genuinely a *count*, not weight: the same bytes are evaluated either way. That is what -makes the conflict worth deciding rather than absorbing silently, because the gate's proxy and the -debt rule's proxy disagree at exactly this shape. +The increase is a module **count**, not additional evaluation weight. +The same bytes evaluate in either structure. +The gate proxy and file-size proxy conflict at precisely this structure, so the conflict needs an explicit decision. ## Decision -May a byte-neutral re-homing of a hub module's contents into new modules raise the eager module -count of the entry surfaces that reach it? **Yes — exactly when it is a split**, decided by three -facts computed from the two closure graphs and the merge-base tree, not by an entered number: - -1. **Containment:** the head closure evaluates every module the merge-base closure evaluated - (renames canonicalized), so nothing was dropped or swapped. -2. **Added-module novelty:** every module the head closure evaluates that the base closure did - not is absent from the merge-base tree at its rename-canonicalized path AND carries no code - from a source deleted since the merge-base — matched when the added module shares **two or - more identical normalized top-level statements** with one deleted source; a single shared - statement is treated as coincidence, because short constants (a schema version, a default - timeout) repeat across the repo and branding every reuse a move would poison new files). - A module the tree already had, sitting outside the closure, becoming eager — under its own - name, a renamed one, or a rewritten move that evades git's `-M` similarity pairing — is a - new edge, not re-homed code; and without this fact, weight deleted elsewhere in the closure - would fund it. Rename canonicalization preserves modules ALREADY IN the base closure; it is - not a pass for an external module moved in, which fails the check at the base path the - merge-base necessarily still has. -3. **Flat weight:** the closure's total top-level statement count — wiring lines excluded, so - re-homing is invisible to it — does not exceed the merge-base closure's. - -This is issue #2469's candidate 4 (structure ∧ weight), with the structure half implemented as -containment + novelty rather than candidate 2's importer-set shape. Any subset is refutable — -containment without novelty accepts pulling a pre-existing module in at flat weight, novelty -without containment accepts dropping a merge-base module, weight without the other two accepts -unrelated shrinkage paying for a smuggled edge — and the three together admit healthy extraction -while refusing any growth in the closure's module-scope statement weight. Statements were chosen -over bytes because formatting and comment churn move bytes while statements track the thing being -preserved: module-scope declarations and calls. The guarantee is exactly **newly evaluated -modules are new code — new paths that share at most one coincidental statement with any source -deleted since the merge-base (`movedFromDeletedSource`'s deliberate two-statement move-signal -threshold) — and total statement weight is flat over a preserved closure**, not -equality of eager behavior: a split that REPLACES an existing statement with a more expensive -top-level call inside an existing file moves no count and no weight, so it passes — nothing -count-based, in modules or statements, can see a like-for-like replacement. That residue is -bounded by what the tolerance cannot hide: every module the head newly evaluates is a path the -merge-base tree did not have AND is not a verbatim transplant — the move probe above brands any -added module sharing two or more statements with one deleted source as pre-existing code. -The probe is deliberately conservative and textual: -code REWRITTEN or reformulated during a move does not match it and would pass as novel, so what -is verified is "no verbatim transplant of a deleted source," not "no repurposed logic" (the -weight fact is what still refuses a transplant made heavier). What keeps the residue small is -everything around the probe: the hard ADR-0019 checks (façade exactness, -no platform implementation before binding) stay count- and pattern-based under it, and the -added-module listing every failure prints is what a reviewer reads. The planted tests in -`scripts/__tests__/closure-growth-rule.test.ts` pin every failing direction. - -Maintenance cost of an ordinary extraction under this rule: zero configuration edits. No row, -baseline, or number is touched per split — the merge-base comparison recomputes everything. (The -candidate-3 approval-row design instead costs one hand-edited row per split plus its retirement.) - -The `AGENTS.md` 1,000-line rule now has no hub exception to name: a statement-neutral split is -shippable, so an oversized hub is addressable debt rather than accepted state. +Byte-neutral relocation of hub contents may increase eager module count **if and only if the relocation is a split**. +Three facts determine eligibility from the closure graphs and merge-base tree, without a manually entered number: + +1. **Containment.** + The head closure evaluates every module in the merge-base closure, with canonicalized renames. + It removes or replaces none. +2. **Added-module novelty.** + Each module newly evaluated by the head is absent from the merge-base tree at its rename-canonicalized path. + It must also carry no code from a source deleted since the merge-base. + The check matches an added module when it shares two or more identical normalized top-level statements with one deleted source; that is a move signal. + One shared statement counts as coincidence. + Short constants, such as schema versions or default timeouts, repeat across the repository. + Classification of every single-statement reuse as a move would incorrectly reject new files. + A pre-existing module outside the closure that becomes eager is a new edge, not relocated closure code. + This applies under its original path, a renamed path, or a rewritten move that escapes git `-M` pairing. + Without novelty, weight deleted elsewhere in the closure would fund that new edge. + Rename canonicalization preserves modules **already inside** the base closure. + An external module moved inside fails at its base path, which remains present in the merge-base tree. +3. **Flat weight.** + Total top-level statement count cannot exceed the merge-base closure's count. + Wiring lines are excluded, so relocation is invisible to this count. + +This implements #2469 candidate 4, structure ∧ weight. +Structure uses containment and novelty instead of candidate 2's importer-set structure. +Each incomplete combination has a counterexample: + +- Containment without novelty permits a pre-existing external module at flat weight. +- Novelty without containment permits removal of a merge-base module. +- Weight without both structure checks permits unrelated shrinkage to fund an additional edge. + +Together, the three facts permit extraction without increased module-scope statement weight. +Statements measure the preserved declarations and calls more accurately than bytes. +Formatting and comments change bytes without changing those statements. + +The guarantee is exactly these facts: + +- Newly evaluated modules are new code: their paths are absent from the merge-base tree. +- Each shares at most one coincidental statement with any source deleted since the merge-base. +- Total statement weight stays flat over a preserved closure. + +`movedFromDeletedSource` deliberately uses the two-statement move threshold. +The guarantee does not establish equal eager behavior. +A split that replaces an existing statement in an existing file with a more expensive top-level call changes neither count nor weight. +Therefore it passes. +No count-based check, of modules or of statements, can detect a like-for-like replacement. +The remaining risk is bounded by new-path novelty and the transplant probe. +Any added module with two or more shared normalized statements from one deleted source counts as pre-existing code. + +The probe is deliberately conservative and textual. +Code rewritten or reformulated during a move does not match the probe and would pass as novel. +It verifies "no verbatim transplant of a deleted source", not "no repurposed logic". +The weight check still refuses a heavier transplant. +Surrounding checks limit this residual risk: + +- The hard ADR-0019 checks (façade exactness, no platform implementation before binding) remain count- and pattern-based. +- Each failure prints added modules for reviewer examination. +- `scripts/__tests__/closure-growth-rule.test.ts` pins every failing direction. + +An ordinary extraction requires zero configuration edits. +The merge-base comparison recomputes all values without a per-split row, baseline, or number. +Candidate 3's approval-row design instead requires one manual row for each split, and its later retirement. + +The `AGENTS.md` 1,000-line rule therefore needs no hub exception. +A statement-neutral split is shippable, so an oversized hub is addressable debt, not accepted state. ## Alternatives considered and refuted -- **Approved-growth row mirroring `APPROVED_OVER_CEILING` (issue, reason, owner):** refuted. It - would be auditable, but it is an allowlist on the one invariant deliberately given no approval - path, `AGENTS.md` forbids adding one to obtain a pass, and every split would owe a row plus its - retirement — maintenance the chosen rule does not. -- **Structure alone (#2469 candidate 2):** refuted by the planted smuggle test — a "new" module can - carry a new heavy edge, so containment proves nothing about eager work. -- **Containment + weight without added-module novelty (the rule's first draft on #3298):** refuted - by the planted pre-existing-heavy test — deleting statements elsewhere in the closure funds a - static edge to a merge-base module that sat outside it, which is real growth, not a split. -- **Novelty by merge-base tree path alone (the rule's second draft on #3298):** refuted by the - planted rewritten-move test — a move that changes the path and churns formatting reports to - git as delete + add, so an absent destination is not proof of new code. The deleted sources' - statement texts close it down to a stated threshold: two identical normalized statements from - one deleted source is the move signal, one is tolerated as coincidence (short constants repeat - across the repo), and a transplant rewritten statement-by-statement stays outside a textual - probe — what remains heuristic-free is the LIST of deleted sources (git's own delete listing, - needing no similarity pairing to have been attempted), not the - content match. -- **Weight alone (#2469 candidate 1):** refuted by the planted drop test — a swap can keep the - statement total flat while quietly making the closure evaluate different modules. -- **Leave the array in place:** the status quo, not a resolution — it leaves the size debt unowned and - the conflict undocumented. -- **Function-scoped `await import` per family:** passes the gate by making registry construction - asynchronous, which removes the compile-time totality and literal-union guarantees ADR 0008 depends - on. Trading a measured proxy cost for a lost correctness guarantee is the wrong direction. -- **Reduce descriptor verbosity instead of splitting the file:** legitimate and unexplored, but it - changes the declaration vocabulary for all ~80 commands, which is a larger change than the split - and needs its own decision. +- **An approved-growth row like `APPROVED_OVER_CEILING`, with issue, reason, and owner: refuted.** + It is auditable but creates an allowlist for the one invariant that deliberately has no approval path. + `AGENTS.md` prohibits such an allowlist to obtain a pass. + Every split would also require a row and its retirement, unlike the chosen rule. +- **Structure alone, #2469 candidate 2: refuted.** + The planted smuggle test shows that a "new" module can introduce a heavy eager edge. + Containment proves nothing about eager work. +- **Containment and weight without novelty, the first #3298 draft: refuted.** + The planted pre-existing-heavy test deletes statements elsewhere and funds a static edge to a pre-existing external module. + That is actual growth, not a split. +- **Novelty from merge-base tree path alone, the second #3298 draft: refuted.** + The planted rewritten-move test changes a path and formatting until git reports delete plus add. + An absent destination therefore does not prove new code. + Deleted-source statement comparison limits this gap at the explicit two-statement threshold. + One identical statement is tolerated because short constants repeat. + A statement-by-statement rewrite remains outside the textual probe. + Only the deleted-source **list** is heuristic-free. + Git supplies that list without any required similarity-pairing attempt. + Content comparison still uses a threshold. +- **Weight alone, #2469 candidate 1: refuted.** + The planted drop test replaces modules while retaining the same statement total. + A swap can keep the statement total flat while the closure evaluates different modules. +- **Leave the array in place.** + This is the status quo, not a resolution. + It leaves the size debt without an owner and the conflict without documentation. +- **Function-scoped `await import` per family.** + This passes by making registry construction asynchronous. + It removes ADR 0008's compile-time totality and literal-union guarantees. + A proxy improvement does not justify loss of a correctness guarantee. +- **Reduce descriptor verbosity instead of splitting.** + This is legitimate but unexamined. + It changes the declaration vocabulary for all approximately 80 commands, exceeds the split's scope, and needs a separate decision. ## Recovery -A complete, byte-faithful split of the descriptor array exists on -`refactor/collocation-registry-family-split` (`40bf719a8b`, split commit `ad3aaa520e`) and is pushed. -With this decision accepted, that branch is now shippable: its +11 is a statement-neutral split of -exactly the tolerated shape, so it needs no gate change beyond this one. +A complete byte-faithful split exists on pushed branch `refactor/collocation-registry-family-split`. +Its head is `40bf719a8b`, with split commit `ad3aaa520e`. +Acceptance makes that branch shippable. +Its +11 is a statement-neutral split with exactly the permitted structure. +It needs no gate change beyond this decision. diff --git a/docs/adr/0029-daemon-policy.md b/docs/adr/0029-daemon-policy.md index 4251a5eacb..a78b9fc943 100644 --- a/docs/adr/0029-daemon-policy.md +++ b/docs/adr/0029-daemon-policy.md @@ -6,28 +6,27 @@ Accepted (2026-09-29). ## Rules at a glance -1. A **daemon policy** is one declarative JSON file named by `AGENT_DEVICE_DAEMON_POLICY`. The - daemon loads it once at start and never reloads it. A policy that cannot be read or validated - stops the daemon from starting. -2. The policy applies to **every request the daemon admits**, whatever transport it came from. It is - a property of the daemon instance, not of a request's origin. -3. It is enforced at three points, each below the one before: - - **Request gate** — `createRequestExecutionScope` refuses denied commands, a `batch` that names - a denied step, `close --shutdown` when `device-shutdown` is denied, and an explicit - `--udid`/`--serial` outside the device scope. `batch` steps and `replay` actions re-enter this - function, so they are gated too. - - **Device scope** — request runtime bindings refuse a resolved device outside the scope before - the gateway binds it, and device inventory (therefore `devices` and device selection) lists - only allowed devices. - - **Capability gate** — the device-shutdown host capability refuses every shutdown, whichever - command asks for it. The one exception is Apple readiness rolling back a Simulator boot that - the same request started and then canceled: the Simulator was not running before. -4. A denial is `UNAUTHORIZED` with `details.reason: 'DAEMON_POLICY_DENIED'`, the `rule` - (`command`, `device`, `capability`, or `lease`), the policy digest, `retriable: false`, and a hint. It - never names the policy's host path. -5. The daemon publishes the policy digest in `daemon.json`. A client that names a policy refuses to - reuse a daemon that enforces a different one, or none. A client that names no policy reuses - whatever the daemon enforces. +1. A **daemon policy** is one declarative JSON file selected by `AGENT_DEVICE_DAEMON_POLICY`. + The daemon loads it once at startup and never reloads it. + If the file cannot be read or validated, the daemon cannot start. +2. Policy applies to **every admitted daemon request**, regardless of transport. + It belongs to the daemon instance, not request origin. +3. Policy is enforced at three points, each below the one before: + - **Request gate:** `createRequestExecutionScope` refuses denied commands and `batch` requests with denied steps. + It refuses `close --shutdown` when policy denies `device-shutdown`. + It refuses explicit `--udid`/`--serial` values outside device scope. + `batch` steps and `replay` actions re-enter this function and receive the same checks. + - **Device scope:** before gateway binding, request runtime bindings refuse resolved devices outside scope. + Inventory lists only allowed devices, including for `devices` and device selection. + - **Capability gate:** the device-shutdown host capability refuses shutdown from every command. + The only exception permits Apple readiness to roll back a Simulator boot started and then canceled by the same request. + That Simulator was not previously running. +4. Denials use `UNAUTHORIZED`, `details.reason: 'DAEMON_POLICY_DENIED'`, `retriable: false`, a hint, and the policy digest. + They include `rule`: `command`, `device`, `capability`, or `lease`. + They never expose the policy's host path. +5. The daemon publishes its policy digest in `daemon.json`. + A client that names a policy refuses to reuse a daemon that enforces a different policy or no policy. + A client that names no policy reuses the daemon, whatever policy the daemon enforces. ## Policy shape @@ -41,98 +40,114 @@ Accepted (2026-09-29). } ``` -- `devices.allow` — device ids the daemon may bind. Absent: every device. -- `commands` — exactly one of `allow` or `deny`. Which daemon commands a rule decides, and by what - name, is derived from the command registry, never from a hand list: - - public commands, by their own name; - - internal commands with platform execution, by the public command their - `catalog.servesPublicCommand` names (`install_source` as `install-from-source`), or else by - their own name (`runtime`, which Maestro flows send); - - internal commands with no platform execution (leases, `human_control`, session bookkeeping) are - protocol plumbing that no rule decides; - - any other name is decided by the rules, so an allow list fails closed for it. - - Names are checked against that vocabulary at load; a local-cli name such as `react-devtools` is - refused, because it runs in the client. With `allow`, commands added by a later - upgrade are denied by default. -- `capabilities.deny` — operations denied whichever command reaches them. `device-shutdown` is the - only capability today. -- `leases.require` — `macos-app`, the only accepted value. Every request must be admitted under a - `macos-app` lease and pass its command allow list (ADR 0007), including commands lease admission - otherwise exempts; of those, only `lease_heartbeat` and `lease_release` pass. A request naming no - lease is refused instead of being admitted unleased, with `rule: 'lease'`. A host that confines - clients to `macos-app` leases sets it so a client cannot drop its lease to reach the desktop. - -Unknown keys are errors, so a misspelled rule cannot silently become no rule. +- `devices.allow` identifies devices that the daemon may bind. + If absent, every device is allowed. +- `commands` contains exactly one of `allow` or `deny`. + The command registry determines applicable daemon commands and names, without a manual list: + - Public commands use their own names. + - Internal commands with platform execution use their `catalog.servesPublicCommand` name when present. + For example, `install_source` uses `install-from-source`. + Otherwise, they use their own names, such as Maestro flows' `runtime`. + - Rules do not decide internal commands without platform execution. + These commands (leases, `human_control`, and session bookkeeping) are protocol plumbing. + - Rules decide every other name, so an allow list fails closed for it. + Load-time validation checks names against this vocabulary. + It refuses local-cli names such as `react-devtools` because they execute in the client. + With `allow`, commands added by later upgrades are denied by default. +- `capabilities.deny` denies operations regardless of the command that reaches them. + Currently, `device-shutdown` is the only capability. +- `leases.require` accepts only `macos-app`. + Each request must have `macos-app` lease admission and pass its command allow list under ADR 0007. + This includes commands otherwise exempt from lease admission. + Of those exempt commands, only `lease_heartbeat` and `lease_release` pass. + A request without a lease is refused with `rule: 'lease'` instead of unleased admission. + Hosts that confine clients to `macos-app` leases set this requirement. + A client therefore cannot discard its lease to reach the desktop. + +Unknown keys are errors. +A misspelled rule cannot silently remove a restriction. ## Context -`agent-device proxy` and hook-authenticated HTTP daemons expose one host's devices to remote -agents. Operators need to confine a remote agent to one simulator and stop it from shutting that -simulator down. The HTTP auth hook (`AGENT_DEVICE_HTTP_AUTH_HOOK`) sees every top-level RPC, so it -could refuse commands, but it is the wrong owner for this: +`agent-device proxy` and hook-authenticated HTTP daemons expose host devices to remote agents. +Operators need to confine a remote agent to one simulator and prevent the agent from shutting down that simulator. +The HTTP auth hook, `AGENT_DEVICE_HTTP_AUTH_HOOK`, sees every top-level RPC and could refuse commands. +It cannot correctly own operation policy for these reasons: -- `batch` steps and `replay` actions run inside the daemon and never pass the HTTP edge again, and - `shutdown` is batchable. -- A device name (`--device "iPhone 16"`) is resolved inside the daemon; the edge only sees the name. +- Internal `batch` steps and `replay` actions never pass the HTTP edge again. + `shutdown` supports batch execution. +- The daemon resolves names such as `--device "iPhone 16"`. + The HTTP edge sees only the name. - `close --shutdown` and lifecycle close reach the same shutdown capability as `shutdown`. ## Decision -Authorization of operations lives in the daemon, next to the request admission, device binding, and -host capability it constrains. The HTTP auth hook keeps authentication and tenant attestation. +The daemon owns operation authorization beside the constrained request admission, device binding, and host capability. +The HTTP auth hook retains authentication and tenant attestation. ### Why the policy ignores request origin -Remote-origin requests carry `internal.publicNetworkOnly`, but `replay` and `test` copy it to child -requests by hand. A rule keyed on origin inherits that propagation risk: a child request that loses -the marker would escape the policy. A daemon-wide rule has nothing to propagate. A host operator who -needs unrestricted access runs `simctl`/`adb` directly, or starts a daemon with another `--state-dir` -and without `AGENT_DEVICE_DAEMON_POLICY` set; a separate state dir alone still loads the policy the -environment names. +Remote requests carry `internal.publicNetworkOnly`. +`replay` and `test` manually copy it to child requests. +An origin-based policy inherits this propagation risk. +A child that loses the marker would escape policy. +A daemon-wide policy requires no propagation. + +An operator needing unrestricted access runs `simctl`/`adb` directly. +Alternatively, the operator starts a daemon with another `--state-dir` and no `AGENT_DEVICE_DAEMON_POLICY`. +A separate state directory alone still loads the policy selected by the environment. ### Why three enforcement points -The request gate gives an early, specific error before any lock or device resolution. It cannot see -the device a name resolves to, and it cannot anticipate every future command that shuts a device -down. The device scope sees the resolved device on every binding; the capability gate sees every -shutdown. Each later point is the backstop for the earlier one, so a new command or flag cannot -bypass the policy by reaching the operation another way. +The request gate produces an early, specific error before locks or device resolution. +It cannot see the device behind a name or anticipate every future shutdown command. +Device scope sees the resolved device on every binding. +The capability gate sees every shutdown. +Each later point is the backstop for the earlier point. +A new command or flag therefore cannot evade policy through another route to the operation. ### Why fail closed and never reload -A daemon that started without its operator's policy, or that swapped policies mid-session, would -make every earlier admission decision stale. Loading once and refusing to start on an invalid file -means a running daemon has exactly one policy, and its digest identifies it. +Startup without the operator's policy or a policy replacement mid-session would make every earlier admission decision stale. +The daemon loads the policy once and refuses to start on an invalid file. +Thus a running daemon has exactly one policy. +Its digest identifies that policy. ## Consequences -- An operator can pin a remote daemon to one simulator and deny shutdown with a five-line file. -- Sessions, leases, and claims keep their existing semantics; the policy only removes options. -- The policy cannot yet be inspected over RPC. Agents learn it from denial errors, which carry the - rule and a do-not-retry hint. -- Lease allocation for a device outside the scope is not refused at allocation time; the device - scope refuses the first request that binds it. -- Startup and shutdown recovery of the daemon's own durable resources (app-log processes, device - claims, session teardown) is not request work and is not device-scoped: refusing it would leak - the processes and claims it exists to release. It acts only on records in the daemon's own state - dir. The capability gate still applies, so recovery cannot shut a device down. -- `AGENT_DEVICE_DAEMON_POLICY` configures a daemon started on this host. A client connected to a - remote daemon (`AGENT_DEVICE_DAEMON_BASE_URL`, `connect proxy`) neither sends nor verifies it; - the remote host's operator owns that daemon's policy. Publishing the digest over `/health` so a - remote client can check it is a follow-up that needs a daemon wire change. -- An invalid policy's startup error names the policy path on the daemon's stderr. That output - reaches only the host process that started the daemon, and the operator needs the path to fix - the file; request-time denials never name it. -- A local caller on the host that triggers daemon takeover (for example by asking for a transport - the running daemon does not serve) starts the replacement with its own environment. A caller that - names no policy therefore gets a daemon without one. Remote clients reach the daemon only over - HTTP and cannot trigger takeover; local callers are the operator's own processes. +- A five-line file can restrict a remote daemon to one simulator and deny shutdown. +- Sessions, leases, and claims retain their semantics. + Policy only removes options. +- RPC cannot yet inspect policy. + Agents receive policy information through denials with the rule and a do-not-retry hint. +- Lease allocation outside device scope is not refused during allocation. + Device scope refuses the first request that binds that device. +- Startup/shutdown recovery of daemon-owned durable resources is not request work and has no device scope. + These resources include app-log processes, device claims, and session teardown. + Refusal would retain the processes and claims that recovery must release. + Recovery acts only on records in the daemon's own state directory. + The capability gate still applies, so recovery cannot shut down devices. +- `AGENT_DEVICE_DAEMON_POLICY` configures daemons started on this host. + Clients using `AGENT_DEVICE_DAEMON_BASE_URL` or `connect proxy` neither send nor verify it. + The remote host operator owns remote daemon policy. + Publication of the digest through `/health` for remote verification requires a later daemon wire change. +- Invalid-policy startup errors name the policy path on daemon stderr. + Only the initiating host process receives this output. + The operator needs the path to repair the file. + Request denials never name it. +- A local caller that triggers takeover starts the replacement daemon with its own environment. + For example, it may request transport absent from the running daemon. + If that caller names no policy, its replacement has no policy. + Remote clients use only HTTP and cannot trigger takeover. + Local callers are the operator's own processes. ## Rejected alternatives -- **Policy in the HTTP auth hook or the proxy.** Both see only top-level RPCs; see Context. -- **Per-request or per-tenant policy.** Needs authenticated identity first; an authenticating front end - can layer per-user rules on top of this per-daemon floor. -- **A programmatic policy module.** Deferred. If added, it must decide at the same three points, not - at the HTTP edge. +- **Policy in the HTTP auth hook or proxy.** + Both see only top-level RPCs; see Context. +- **Per-request or per-tenant policy.** + This first requires authenticated identity. + An authenticating front end can add per-user rules on top of this per-daemon policy. + This per-daemon policy is the floor. +- **A programmatic policy module: deferred.** + If added, it must decide at the same three points, not at the HTTP edge. diff --git a/docs/adr/0030-process-lock-exclusion.md b/docs/adr/0030-process-lock-exclusion.md index 55b7f403e9..c420fdb189 100644 --- a/docs/adr/0030-process-lock-exclusion.md +++ b/docs/adr/0030-process-lock-exclusion.md @@ -2,74 +2,99 @@ ## Status -Accepted for the hardened host-kit protocol. Mixed access with the legacy host-kit protocol -is unsupported. +Accepted for the hardened host-kit protocol. +Mixed access with the legacy host-kit protocol is unsupported. ## Rules at a glance -- Publication, reclaim and release hold the same non-expiring mutation guard. -- The guard covers only filesystem compare-and-mutate steps. Owner liveness is judged before - the guard is taken; under it, a reclaim only confirms that the record it judged is unchanged. -- Release waits a bounded time for a held guard. A guard still held after that bound leaves the - release unverified, as an unprovable owner record or a directory it cannot remove also does. -- A claim identifies an acquisition; a PID alone cannot authorize release. -- Unknown owners and abandoned guards remain retained. Age never proves abandonment. -- Before upgrading, stop every legacy process using the shared state or cache paths. Keep - legacy versions from returning while hardened users operate those paths. Use a single - deployed version or separate environments; deleting a lock is not an upgrade procedure. -- Manual guard recovery requires external confirmation that every user of its paths stopped. - -The implementation and its executable invariants live in -[`process-lock.ts`](../../packages/host-kit/src/internal/process-lock.ts) and its sibling tests. +- Publication, reclaim, and release hold the same mutation guard without expiry. +- The guard covers only filesystem compare-and-mutate steps. + Owner liveness is determined before guard acquisition. + Under the guard, reclaim only confirms that the evaluated record is unchanged. +- Release waits a bounded time for a held guard. + If the guard is still held after that bound, release remains unverified. + An unprovable owner record or an unremovable directory also leaves release unverified. +- A claim identifies an acquisition. + A PID alone cannot authorize release. +- Unknown owners and abandoned guards remain retained. + Age never proves abandonment. +- Before an upgrade, stop every legacy process that uses the shared state or cache paths. + While hardened users operate those paths, prevent legacy versions from returning. + Use one deployed version or separate environments. + Lock deletion is not an upgrade procedure. +- Manual guard recovery requires external confirmation that every user of the guard's paths has stopped. + +[`process-lock.ts`](../../packages/host-kit/src/internal/process-lock.ts) and sibling tests own the implementation and executable invariants. ## Why the guard does not expire -A process can pause after creating a directory or verifying a dead claim. Expiring its guard -lets another process enter, then the first process resumes and overwrites or deletes the -replacement. Retaining an uncertain guard trades automatic crash recovery for exclusion. - -The guard is an exclusive file at the existing guard path. Legacy age-based `rmdir` cannot -remove it, so a fresh legacy reclaimer cannot steal a hardened publisher's guard. This does -not revoke a legacy reclaimer admitted before cutover: another legacy process may already -have stolen its directory guard. A real paused-child experiment reproduced both protocols -returning acquired after that older reclaimer resumed. Quiescence must cover all legacy -users, and continued mixed access remains unsupported. - -A different lock path would split exclusion. A version check, an owner re-read or a new -guard cannot constrain legacy code after its final check. The support boundary therefore -requires deployment control; host-kit does not claim to detect or evict every legacy user. - -Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and -refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an -exclusive file; a hardened daemon creates a directory at that same path. The old acquisition -cannot unlink a directory, and the new acquisition retains an existing file. - -The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real -children using the c237027737 legacy acquisition and the current owner. They cover an older -daemon already running, a hardened owner waiting to publish metadata, and concurrent startup. -The client refuses an older registration before signaling or changing it. This proves the daemon -cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still -require the deployment controls above. +A process can pause after directory creation or dead-claim verification. +Guard expiry admits another process. +The first process can then resume and overwrite or delete the replacement. +Retention of an uncertain guard sacrifices automatic crash recovery to preserve exclusion. + +The guard is an exclusive file at the existing guard path. +Legacy age-based `rmdir` cannot remove it. +A new legacy reclaimer therefore cannot take a hardened publisher's guard. +However, this does not revoke a legacy reclaimer admitted before cutover. +Another legacy process may already have stolen its directory guard. +A real paused-child experiment reproduced both protocols returning acquired after the old reclaimer resumed. +Quiescence must include every legacy user. +Continued mixed access remains unsupported. + +A different lock path would divide exclusion. +Version checks, owner re-reads, and new guards cannot constrain legacy code after its final check. +The support boundary therefore requires deployment control. +Host-kit does not claim detection or eviction of every legacy user. + +Daemon registration has a separate cutover boundary. +It keeps the same `daemon.lock` path. +It refuses legacy files instead of reclaiming them automatically. +A legacy daemon creates an exclusive file. +A hardened daemon creates a directory at that same path. +Old acquisition cannot unlink a directory. +New acquisition retains an existing file. + +[Registration tests](../../src/__tests__/daemon-registration-owner.test.ts) use real children with the c237027737 legacy acquisition and current owner. +They cover these cases: + +- An older running daemon. +- A hardened owner before metadata publication. +- Concurrent startup. + +The client refuses older registration before a signal or modification. +This proves the daemon cutover. +It does not expand the host-kit mixed-protocol support contract. +Older clients still require the deployment controls above. ## Recovering retained legacy daemon state -Stop every daemon and client using the affected state directory, including older versions, -and verify that those processes have exited. Keep them stopped throughout recovery. -Only then remove the legacy `daemon.lock` file and any retained `daemon.reclaim.lock` guard -from that directory. Preserve the other state, logs and repair evidence. +1. Stop every daemon and client that uses the affected state directory, including older versions. +2. Verify that those processes exited. +3. Keep them stopped throughout recovery. +4. Only then remove the legacy `daemon.lock` file and any retained `daemon.reclaim.lock` guard from that directory. +5. Preserve other state, logs, and repair evidence. +6. Start one supported version with that directory. +7. Prevent older clients or daemons from returning. -Start one supported version using that directory and prevent older clients or daemons from -returning. If every user cannot be identified and stopped, retain the files and use a separate -state directory. This procedure applies to daemon registration; shared build and cache locks -require the same confirmation for every process using their own paths. +If you cannot identify and stop every user, retain the files. +In that case, use a separate state directory. +This procedure applies to daemon registration. +Shared build/cache locks require the same confirmation for every process that uses their paths. ## Registration operations -[Shared retirement](../../packages/daemon-contracts/src/daemon-registration-owner.ts) owns verified termination, protected -metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout -reset and manual stop await its result. Abandoned recovery uses the same protected retirement -sequence without signaling a live process. Daemon publication and shutdown use functions bound -to their acquired claim. +[Shared retirement](../../packages/daemon-contracts/src/daemon-registration-owner.ts) owns verified termination, protected metadata inspection/removal, and release. +These operations await its result: + +- Takeover. +- Failed startup. +- Replay cleanup. +- Timeout reset. +- Manual stop. + +Abandoned recovery uses the same protected retirement sequence without a signal to a live process. +Daemon publication and shutdown use functions bound to their acquired claim. ```mermaid flowchart LR diff --git a/docs/adr/0031-macos-native-app-backend.md b/docs/adr/0031-macos-native-app-backend.md index 228bb88980..e4b337e694 100644 --- a/docs/adr/0031-macos-native-app-backend.md +++ b/docs/adr/0031-macos-native-app-backend.md @@ -2,96 +2,118 @@ ## Status -Accepted (2026-10-03). Opt-in; XCTest stays the default app-session backend. Evidence gap: pointer -event delivery was re-measured on 2026-10-05 (#3213); Electron, Catalyst, and wheel delivery with -window fields are unmeasured. +Accepted (2026-10-03). Native is opt-in; XCTest remains the default app-session backend. +Pointer event delivery was measured again on 2026-10-05 (#3213). +Electron, Catalyst, and wheel delivery with window fields remain unmeasured. ## Rules at a glance -1. `AGENT_DEVICE_MACOS_APP_BACKEND` selects the backend for macOS `app` sessions: `xctest` - (default) or `native`. It is a daemon setting read through `readMacOsAppBackend`: once by the - Apple runtime owner, on its first macOS device, and once per macOS interactor. Other Apple - devices never read it. An unknown value fails macOS sessions with `INVALID_ARGS`, never a - silent fallback. -2. `macOsSurfaceBackend(surface, appBackend)` in `packages/contracts/src/session-surface.ts` is the - one routing decision. With `native`, the `app` surface is helper-routed exactly like - `frontmost-app`, `desktop`, and `menubar`. -3. A native daemon never starts XCTest on macOS. `macOsNativeBackendFacts` refuses every macOS - operation only the runner serves (recording, `prepare`, `back`, press and hold, gestures and - their viewport) at admission, for every macOS session, with `UNSUPPORTED_OPERATION` and - `reason: 'unsupported-device-backend'`, the reason the physical-iOS XCTest backend uses. - `macOsNativeAppInteractor` is assembled member by member, so no runner-backed member reaches - it; an action that names no app is refused rather than handed to the runner. -4. Pointer actions are accessibility actions only: `AXPress`, focus, value, selected text, or a - scroll bar value. Press and hold is refused at admission; double, secondary, and middle clicks - are refused by the interactor before the helper runs. A press or fill with no pressable - element, or a scroll with no settable scroll bar, is refused by the helper and carries its - `helperReason` under the same `reason`; that vocabulary is pinned by - `contracts/fixtures/macos-native-helper-outcomes.json`. Only keyboard text falls back to events - posted to the app's process. -5. Snapshots of a Chromium-based session app (one shipping `chrome_100_percent.pak`) enable its - accessibility tree (`AXManualAccessibility`, else `AXEnhancedUserInterface`) before traversal. - Only the snapshot that turns the tree on waits for it (up to 1 s); a tree that does not - populate adds a snapshot warning. The tree stays on for the app's lifetime, as for any - assistive client. The app surface walks up to 48 levels deep and reports a deeper tree as - truncated; other helper surfaces keep 12. -6. Screenshots capture the session app's front window by itself through ScreenCaptureKit. +1. `AGENT_DEVICE_MACOS_APP_BACKEND` selects `xctest` (default) or `native` for macOS `app` sessions. + `readMacOsAppBackend` reads this daemon setting once for the Apple runtime owner's first macOS device and once per macOS interactor. + Other Apple devices never read it. + Unknown values fail macOS sessions with `INVALID_ARGS`, without silent fallback. +2. `macOsSurfaceBackend(surface, appBackend)` in `packages/contracts/src/session-surface.ts` owns the single routing decision. + With `native`, the `app` surface is helper-routed exactly like `frontmost-app`, `desktop`, and `menubar`. +3. A native daemon never starts XCTest on macOS. + For every macOS session, `macOsNativeBackendFacts` refuses every runner-only operation at admission: + - Recording. + - `prepare`. + - `back`. + - Press and hold. + - Gestures and their viewport. + Refusal uses `UNSUPPORTED_OPERATION` with `reason: 'unsupported-device-backend'`, as the physical-iOS XCTest backend does. + `macOsNativeAppInteractor` is assembled member by member, so no runner-backed member reaches it. + Actions without a named app are refused instead of sent to the runner. +4. Pointer actions use only accessibility: `AXPress`, focus, value, selected text, or scroll bar value. + Admission refuses press and hold. + Before helper execution, the interactor refuses double, secondary, and middle clicks. + The helper refuses press/fill without a pressable element and scroll without a settable scroll bar. + It supplies `helperReason` under the same `reason`. + `contracts/fixtures/macos-native-helper-outcomes.json` pins this vocabulary. + Only keyboard text falls back to events posted to the app process. +5. Before traversal, snapshots enable Chromium session-app accessibility through `AXManualAccessibility`, or `AXEnhancedUserInterface` if necessary. + A shipped `chrome_100_percent.pak` identifies these apps. + Only the snapshot that enables the tree waits for it, for at most 1 s. + If the tree does not populate, the snapshot adds a warning. + The tree remains enabled for the app lifetime, as with any assistive client. + App-surface traversal walks at most 48 levels deep and reports deeper trees as truncated. + Other helper surfaces retain 12 levels. +6. Screenshots capture only the session app's front window through ScreenCaptureKit. ## Context -The XCTest runner drives a macOS app through XCUIApplication, which puts the host in Automation -Mode: a system overlay is shown and the runner moves the shared pointer. The macOS helper already -read the accessibility tree for helper surfaces, and the session snapshot vocabulary is the same, -so an app session can be served without a test session while the app stays behind the user's -windows. +XCTest drives a macOS app through XCUIApplication and puts the host in Automation Mode. +Automation Mode shows a system overlay. +The runner moves the shared pointer. +The macOS helper already reads accessibility trees for helper surfaces with the same session snapshot vocabulary. +It can therefore serve an app session without a test session while the app remains behind the user's windows. ## Decision details -**Pointer delivery.** Events posted to a process with `CGEventPostToPid` were measured against -Calculator (SwiftUI): mouse events were dropped whether the app was frontmost or in the background, -and wheel events were dropped in the background, while every post reported success. Those events -named no window. A re-measurement against a background AppKit, SwiftUI, and WKWebView fixture -(#3213) found the window fields decide delivery, not the API: with the target window number in -event field 51 and either field 58 or a window-local location (`CGEventSetWindowLocation`), the -public `CGEventPostToPid` delivered click, double-click (click state in field 1), and secondary -click to all three, 54 of 54, without activating the app or changing the frontmost app. Fields 51 -and 58 are undocumented. Drags and WKWebView button clicks were delivered only after a private -focus-without-raise activation. Keyboard events were accepted by Calculator and by Electron apps in -the background. A dropped post still reports success, and a fallback that cannot be observed to -work would turn "nothing happened" into success, so pointer actions have no event fallback until -an event path verifies its outcome. - -**Target resolution.** The helper acts only on a point inside one of the session app's own on-screen -windows (its menus and sheets included) and never on its menu bar, whose Apple menu acts for the whole Mac -(Sleep, Lock Screen, Recent Items); other points are refused with `no-accessible-target`. It -hit-tests inside the session app (other apps' windows above it do not answer) and walks at most four ancestors for a text input or a pressable control role. -Chromium answers a hit test with wrapper groups that all claim `AXPress`, so when the chain names -no control the helper picks the smallest such element whose frame contains the point, searching -only the window the hit landed in (the app's front on-screen window when the hit names none). A -group is pressed only when no control contains the point. Responses name the window acted in -(`windowTitle`) when the window has an accessibility title. This resolves the same point the daemon computed from the snapshot node; the -daemon dispatch paths and their ADR 0011 guarantees are unchanged. - -**Pointer dispatch, not element identity.** The daemon dispatches every platform by point, and -the occlusion, offscreen, and parent-owned touch-point guarantees of ADR 0011 are decided on that -point. Sending an element identity instead would be a new dispatch path with its own guarantee -row, and an `AXUIElement` cannot outlive the one-shot helper process that resolved it, so an -identity would be a tree path re-resolved against a tree that may have changed. - -**Ghost cursor.** Each helper action draws its own pointer, glides onto the target from a short -fixed offset, and pulses on delivery, so a person can follow the agent without losing the real -pointer. It costs about 0.3 s per action and keeps no state between helper processes; a persistent -pointer would need a long-lived helper. `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` disables it. +**Pointer delivery.** +Initial `CGEventPostToPid` measurements used Calculator (SwiftUI) without window fields. +Mouse events were dropped in foreground and background. +Wheel events were dropped in background. +Every post still reported success. + +A second background measurement used AppKit, SwiftUI, and WKWebView fixtures (#3213). +Window fields, not the API choice, determined delivery. +The target window number occupied event field 51. +Either field 58 or window-local `CGEventSetWindowLocation` supplied the remaining context. +Public `CGEventPostToPid` delivered clicks, double-clicks with field 1 click state, and secondary clicks to all three fixtures. +All 54 of 54 cases succeeded without app activation or frontmost-app change. +Fields 51 and 58 are undocumented. +Drags and WKWebView button clicks arrived only after private focus-without-raise activation. +Calculator and Electron accepted background keyboard events. + +A dropped post still reports success. +An unverified event fallback would therefore report success when no action occurred. +Pointer actions have no event fallback until an event path verifies its outcome. + +**Target resolution.** +The helper acts only inside the session app's own on-screen windows, including menus and sheets. +It never acts on the app menu bar. +The Apple menu affects the complete Mac through Sleep, Lock Screen, and Recent Items. +Other points are refused with `no-accessible-target`. +The helper hit-tests only the session app. +Other apps' overlaid windows do not answer. +It traverses at most four ancestors for a text input or pressable control role. + +Chromium hit tests return wrapper groups that all claim `AXPress`. +If the chain names no control, the helper selects the smallest such element whose frame contains the point. +The search covers only the hit window. +If the hit names no window, it uses the app's front on-screen window. +A group is pressed only if no control contains the point. +If the window has an accessibility title, the response reports it as `windowTitle`. +This resolves the same point computed from the snapshot node. +Daemon dispatch paths and their ADR 0011 guarantees remain unchanged. + +**Pointer dispatch, not element identity.** +Daemon dispatch uses points on every platform. +ADR 0011 occlusion, offscreen, and parent-owned touch-point guarantees apply to that point. +Element identity would create a new dispatch path with its own guarantee row. +An `AXUIElement` cannot outlive the one-shot helper that resolves it. +An identity would therefore be a tree path resolved again against a possibly changed tree. + +**Ghost cursor.** +Each helper action draws a separate pointer. +It moves to the target from a short fixed offset and pulses on delivery. +A person can follow the agent without losing the real pointer. +This costs approximately 0.3 s per action and retains no state between helper processes. +A persistent pointer would require a long-lived helper. +`AGENT_DEVICE_MACOS_GHOST_CURSOR=0` disables the ghost cursor. ## Rejected alternatives -- **Suppressing Automation Mode.** `automationmodetool` removes the authentication prompt, not the - overlay, and the runner still owns the pointer. -- **Private SkyLight event delivery** (`SLEventPostToPid`, focus-without-raise). Posting through - `SLEventPostToPid` delivered nothing the same fields did not deliver through the public - `CGEventPostToPid` (#3213). Focus-without-raise (`SLPSPostEventRecordTo`) made the target - AppKit-active while the frontmost app kept the user's keystrokes, and it was the only way drags - reached the fixture, but it is private API that can break with any macOS release. -- **Native by default.** The accessibility path cannot express drags, holds, or double-clicks, and - apps with sparse accessibility trees still need the runner. Defaults change only with coverage - evidence across app frameworks. +- **Suppress Automation Mode.** + `automationmodetool` removes the authentication prompt, not the overlay. + The runner still controls the pointer. +- **Private SkyLight events through `SLEventPostToPid` or focus-without-raise.** + `SLEventPostToPid` delivered no additional events beyond public `CGEventPostToPid` with the same fields (#3213). + Focus-without-raise through `SLPSPostEventRecordTo` made the target AppKit-active while the frontmost app retained user keystrokes. + Only this approach delivered drags to the fixture. + However, the private API can break in any macOS release. +- **Native by default.** + Accessibility cannot express drags, holds, or double-clicks. + Apps with sparse accessibility trees still require the runner. + Defaults change only with coverage evidence across app frameworks. diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 170c1fe457..1c7af1a081 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -2,36 +2,43 @@ ## Status -Accepted (2026-10-07). Spike for #3278 (umbrella #3276). Every measurement is pinned to `d9959f510`, -whose production tree is `7dda0c2bf`'s; the harness lives only at that commit -([Deletion](#deletion)). No rule moves. +Accepted (2026-10-07). This evaluation addresses #3278 under umbrella #3276. +Every measurement refers to `d9959f510`, with the same production tree as `7dda0c2bf`. +The harness exists only at that commit; see [Deletion](#deletion). +No rule moves. ## Rules at a glance -- R2, R4, R5, R6, R77, R78, R14 and R71 stay on the current engine. Neither dependency-cruiser 18.5 - nor fallow 3.32 replaces them. -- Graph-based checks share one normalized import model, today built by `parseImports` and - `resolveImportEdges`. Do not enforce a subset of them from a second, disagreeing import graph. -- The model's implementation remains replaceable. Besides these eight rules, it must supply - resolved edges with value, type-only and dynamic kinds to R9/R10, R19, R73–R76 and - `scripts/depgraph/`; named symbols to R75; and raw specifier sites to R11 and R13. Eleven other - registry entries read source ASTs or text only, and one reads paths only. -- Fix the demonstrated parser gap in #3293: `parseImports` misses TypeScript `import('x').T` type - positions (9 file pairs). It already parses an OXC AST for dynamic imports but scans static - imports with regexes; read both from that AST, `TSImportType` included, keeping symbol and - edge-kind semantics, rather than adding a second extractor. -- Do not upgrade fallow to 3.x for `boundaries`; revisit on a [trigger](#revisit-triggers). +- R2, R4, R5, R6, R77, R78, R14, and R71 retain the current engine. + Neither dependency-cruiser 18.5 nor fallow 3.32 replaces them. +- Graph-based checks share one normalized import model, currently from `parseImports` and `resolveImportEdges`. + Do not enforce some checks through a second, inconsistent graph. +- The model implementation remains replaceable. + In addition to these eight rules, it must supply these outputs: + - Resolved value, type-only, and dynamic edges for R9/R10, R19, R73–R76, and `scripts/depgraph/`. + - Named symbols for R75. + - Raw specifier sites for R11 and R13. + Eleven other registry entries read only source ASTs or text. + One reads only paths. +- Fix #3293's demonstrated parser gap: `parseImports` misses TypeScript `import('x').T` type positions (nine file pairs). + `parseImports` already uses an OXC AST for dynamic imports but regexes for static imports. + Read both from that AST, including `TSImportType`. + Preserve symbol and edge-kind semantics. + Do not add a second extractor. +- Do not upgrade fallow to 3.x for `boundaries`. + Revisit on a [trigger](#revisit-triggers). ## Measured -The edge-set, parity, clean-tree and runtime numbers are the output of the spike harness run at -`d9959f510`. Retrieve it with `git show d9959f510:scripts/layering/boundary-engine-spike.ts`, after -`git fetch origin pull/3286/head` if the commit is not local; usage is in its header. It derives -both engines' R5 and R6 rules from `model.ts`'s `RANKED_ZONES` and `zoneRank`, writes the other -rules' paths inline (R77's from `RUNNER_SUBTREE`), runs only on exactly the tracked production tree -(1,837 files, 41 zones), and gives recorded R6 and R78 edges to each engine's known-violations -baseline. The deletion table and the `.fallowrc.json` field count are counts of files at the same -commit. +The harness run at `d9959f510` supplies edge-set, parity, clean-tree, and runtime measurements. +If the commit is absent locally, first run `git fetch origin pull/3286/head`. +Retrieve the harness with `git show d9959f510:scripts/layering/boundary-engine-spike.ts`. +Its header gives usage. +It derives both engines' R5/R6 rules from `model.ts`'s `RANKED_ZONES` and `zoneRank`. +It declares other rules' paths inline, with R77 paths from `RUNNER_SUBTREE`. +It runs only on exactly the tracked production tree: 1,837 files and 41 zones. +It supplies recorded R6/R78 edges to each engine's known-violations baseline. +The deletion table and the `.fallowrc.json` field count are counts of files at that commit. ### Edge-set diff against `resolveImportEdges` @@ -43,21 +50,29 @@ commit. | kind disagreement | — | 46 pairs | 0 on value vs type | | dynamic kind | yes | yes, 46 pairs mislabelled | not represented | -The 9 extra pairs are `import('./x').T` type positions custom misses; both engines are right. +The nine extra pairs are `import('./x').T` type positions missed by the custom engine. +Both replacement engines correctly detect them. -The umbrella's 3.1.1 cross-check missed 88 dynamic and type-only edges; 18.5 misses none but -mislabels 46 pairs (33 dynamic → type, 8 dynamic+type → type, 5 dynamic+value → value): its -hardcoded `getDependencyUniqueKey` dedupes on `(specifier, moduleSystem, type-only flag)`, so -`typeof import('./x').f` and `await import('./x')` keep whichever comes first. +The umbrella's 3.1.1 cross-check missed 88 dynamic and type-only edges. +Version 18.5 misses none of these edges but mislabels 46 pairs: -fallow's value-versus-type split matches custom on every pair, with no dynamic kind. Its boundary -check follows `export type *` barrels to the declaring module: the import -`src/commands/cli-runner.ts → src/agent-device-client.ts` counts as `commands → client`, one more R6 -finding. Clean tree: custom 0, depcruise 10 (3 R6 survivors, 7 recorded R78 edges), fallow 11. +- 33 dynamic → type. +- 8 dynamic+type → type. +- 5 dynamic+value → value. + +Its hardcoded `getDependencyUniqueKey` deduplicates by `(specifier, moduleSystem, type-only flag)`. +Thus, `typeof import('./x').f` and `await import('./x')` retain whichever appears first. + +Fallow agrees with custom on value/type classification for every pair but has no dynamic kind. +Its boundary check follows `export type *` barrels to the declaring module. +`src/commands/cli-runner.ts → src/agent-device-client.ts` therefore counts as `commands → client`. This adds one more R6 finding. +Clean-tree findings are custom 0, depcruise 10, and fallow 11. +Depcruise's ten are three R6 survivors and seven recorded R78 edges. ### Planted-violation parity -`pass` rows are the closest negative custom admits; ✗ is a missed `flag` or a flagged `pass`. +`pass` rows are the nearest negative cases that custom permits. +✗ means a missed `flag` or incorrectly flagged `pass`. | Plant | Expect | custom | depcruise | fallow | | --- | --- | --- | --- | --- | @@ -85,17 +100,22 @@ finding. Clean tree: custom 0, depcruise 10 (3 R6 survivors, 7 recorded R78 edge | R14 non-TS file under `src/utils` | flag | ✓ | ✗ | ✗ | | R71 file under `src/replay` | flag | ✓ | ✓ | ✓ generic | -_R78 no longer records edges: the client's direct imports are a flat ban in `LOGICAL_MODULE_POLICIES` (R10), and R78 now walks reachability from the CLI entries, so the "unrecorded" and "stale" rows describe the spike at its date, not the rules as shipped._ +_R78 no longer records edges._ +_The `LOGICAL_MODULE_POLICIES` client-import rule in R10 now prohibits direct daemon imports._ +_R78 follows reachability from CLI entries._ +_The "unrecorded" and "stale" rows describe the dated evaluation, not shipped rules._ -Custom prints `file:line`, rule id and hint (`report()` in `scripts/layering/check.ts`); depcruise -the rule name and an authored comment, without a line. "Generic" means a fallow zone pair -(`core → cli`) and a docs link, with no rule id or hint; R14 and R71 surface as "matches no zone". -fallow's R77 rule pack carries an id, message and line. On the stale row, depcruise reports -`summary.baselineStale: 1` and exits 0; fallow's `--fail-on-stale-baseline` exits 1. +Custom `report()` in `scripts/layering/check.ts` prints `file:line`, rule ID, and hint. +Depcruise prints rule name and an authored comment without a line. +"Generic" denotes a fallow zone pair, such as `core → cli`, and a documentation link without rule ID or hint. +R14/R71 appear as "matches no zone". +Fallow's R77 rule pack supplies an ID, message, and line. +For the stale row, depcruise reports `summary.baselineStale: 1` and exits 0. +Fallow's `--fail-on-stale-baseline` exits 1. ### Runtime -Medians of 5 runs after a warm-up, on a shared host (load average 9–17): +These are medians from five runs after warm-up on a shared host with load average 9–17: | | custom | depcruise | fallow | | --- | --- | --- | --- | @@ -105,8 +125,8 @@ Medians of 5 runs after a warm-up, on a shared host (load average 9–17): ### Lines deletable -Rule plus test lines a full migration would delete. The resolver and the R4/R5/R6 pair helpers that -`scripts/depgraph/` reads stay either way. +A complete migration would delete the rule and test lines below. +The resolver and the R4/R5/R6 pair helpers that `scripts/depgraph/` reads remain with either engine. | Rule | Code deleted | Lines | depcruise parity | fallow parity | | --- | --- | --- | --- | --- | @@ -119,54 +139,71 @@ Rule plus test lines a full migration would delete. The resolver and the R4/R5/R | R14, R71 | `retired-paths-policy.ts` | 218 | TS only (R14 gap) | TS only, generic | | shared | registry, imports, success line in `check.ts` | ≈25 | | | -About 1,224 lines, half of them tests; a migration adds a generator like the harness's -`depcruiseConfig` and helpers (122 lines at `d9959f510`). An exact-edge R6 baseline also changes -policy: it rejects swapping one inversion for another within a pair, which the per-pair count -ratchet admits. +Approximately 1,224 lines are deletable, half in tests. +Migration adds a generator such as harness `depcruiseConfig` and helpers, which total 122 lines at `d9959f510`. +An exact-edge R6 baseline also changes policy. +It prohibits replacement of one inversion with another inside a pair. +The current per-pair count ratchet permits that replacement. ## Decision -Retain the current engine because the evaluated replacements do not provide sufficient semantic -parity and net simplification. Graph-based checks share one normalized import model; its -implementation remains replaceable. Continue replacing generic algorithms and compiler-enforceable -rules with maintained tools. Fix the demonstrated parser gap. - -The semantic blockers are the correctness gaps in the parity table: - -- **dependency-cruiser** deduplicates a file's dependencies on a key that omits the dynamic and - `import()`-type kinds. That mislabels 46 pairs and lets a lazy `import()` hide behind an - `import()` type of the same target, the R77 miss. -- **fallow** has no dynamic-import kind, so it flags the lazy seams R5 admits and misses R77's - `import()`. Its rule packs skip files no entry point reaches, so it also misses an orphan runner - module. - -Simplification is limited. dependency-cruiser matches custom on R2, R4, R5 and R71, where migration -would delete about 430 lines. The shared model stays for its other consumers, so the gate would read -two import graphs that disagree. - -The rest are migration costs, which would not decide the question on their own: - -- dependency-cruiser needs `typescript@<7` or `@swc/core` beside TypeScript 7 (+1.6 s), and a - wrapper that fails on `summary.baselineStale`. Its diagnostics carry no line. -- fallow 3.x rejects the 73 `comment` fields in `.fallowrc.json`, and its boundary findings carry no - rule id or hint. It adds no measurable time and has the best stale gate. -- R14 and R71 are filesystem-placement policies. A non-TS path under `src/utils` needs a path check - with either engine; that is not a failure of an import engine. +Retain the current engine, because the evaluated replacements lack sufficient semantic parity and net simplification. +Graph-based checks share one normalized import model. +Its implementation remains replaceable. +Continue replacement of generic algorithms and compiler-enforceable rules with maintained tools. +Fix the demonstrated parser gap. + +The parity table identifies these correctness blockers: + +- **Dependency-cruiser** deduplicates without dynamic or `import()`-type kinds in the key. + It mislabels 46 pairs. + A same-target `import()` type can hide a lazy `import()`, which causes the R77 miss. +- **Fallow** lacks a dynamic-import kind. + It flags the lazy seams that R5 permits and misses R77 `import()`. + Rule packs skip unreachable files and therefore miss an orphan runner module. + +Simplification is limited. +Dependency-cruiser matches custom for R2, R4, R5, and R71, which would delete approximately 430 lines. +Other consumers still need the shared model. +The gate would therefore read two inconsistent graphs. + +These additional migration costs do not independently decide the outcome: + +- Dependency-cruiser needs `typescript@<7` or `@swc/core` beside TypeScript 7, with +1.6 s runtime. + It needs a wrapper that fails on `summary.baselineStale`. + Diagnostics lack a line. +- Fallow 3.x rejects 73 `comment` fields in `.fallowrc.json`. + Boundary findings lack a rule ID and hint. + It adds no measurable runtime and provides the best stale-baseline gate. +- R14/R71 govern filesystem placement. + Both engines still need a path check for non-TS paths under `src/utils`. + This is not an import-engine failure. ## Revisit triggers -- dependency-cruiser keys deduplication on the dependency type. -- fallow boundaries gain a dynamic-import kind, and its rule packs cover files no entry point - reaches. -- An engine can supply the shared import model above to the existing consumers with less total - machinery than `parseImports` and `resolveImportEdges`. The source-only AST checks do not have to - change first. +- Dependency-cruiser includes dependency type in its deduplication key. +- Fallow boundaries gain a dynamic-import kind and rule packs cover unreachable files. +- An engine can supply the shared import model above to the existing consumers with less total machinery than `parseImports` and `resolveImportEdges`. + Source-only AST checks do not need to change first. ## Deletion -The harness is not in the tree. It imports internal symbols from six layering modules (`check.ts`, -`model.ts`, `package-boundaries.ts`, `ratchet-reference.ts`, `apple-runner-host-port-policy.ts`, -`tracked-sources.ts`), and no gate would keep it compiling: `tsconfig.json` does not include -`scripts/layering/`, and fallow ignores it. The change that evaluates a revisit trigger retrieves -the harness from `d9959f510`, ports it to the internals of its own tree, re-runs it, updates the -tables here, and deletes any migrated rule's code listed in the deletion table. +The harness is absent from the tree. +It imports internals from six layering modules: + +- `check.ts` +- `model.ts` +- `package-boundaries.ts` +- `ratchet-reference.ts` +- `apple-runner-host-port-policy.ts` +- `tracked-sources.ts` + +No gate would verify that it still compiles. +`tsconfig.json` does not include `scripts/layering/`, and fallow ignores it. +A change that evaluates a revisit trigger follows this sequence: + +1. Retrieve the harness from `d9959f510`. +2. Adapt it to the current tree's internals. +3. Run it again. +4. Update these tables. +5. Delete migrated rule code listed in the deletion table. diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 5a1dd88ec6..1590bbb700 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -1,33 +1,35 @@ # 0033. Collocation decision table -Status: accepted. Decision pass over the 48 candidate files listed in umbrella issue #3276 §5 -(communities detected at `ff685ad80`: declared zones score 0.373 Louvain modularity versus 0.564 -for detected communities). One row per candidate: `move`, `merge`, or `keep`, with the reason. -Moves land as `refactor(move)` PRs; batch 1 is the companion PR to this decision. - -2026-10-10: `packages/managed-allocation` was removed with the withdrawn allocator design -([ADR 0021](0021-host-simlock-managed-device-allocation.md)). The `src/managed-device-allocation.ts` -row and Batch 1 record a move that landed (#3287) and was then deleted with the package; nothing -moves there now. Rows that refuse a move into it, or cite its capture-kit dependency as a cycle, no -longer bind; re-evaluate those files on their remaining edges. +Status: accepted. +This decision evaluates the 48 candidate files in umbrella #3276 §5. +At `ff685ad80`, declared zones score 0.373 Louvain modularity, compared with 0.564 for detected communities. +Each candidate has a `move`, `merge`, or `keep` decision with rationale. +Moves use `refactor(move)` PRs. +Git history retains completed move batches. + +The allocator design withdrawal removed `packages/managed-allocation` on 2026-10-10; see [ADR 0021](0021-host-simlock-managed-device-allocation.md). +The `src/managed-device-allocation.ts` row and Batch 1 record a move that landed in #3287. +The removal of the package then deleted the moved file. +Nothing moves there now. +Rows that reject this destination or cite its capture-kit dependency as a cycle no longer bind. +Re-evaluate those files against their remaining edges. ## Rules at a glance -- A `keep` row is a decision, not a punt: the reason names the direction that was checked and why - it fails (usually a package cycle or a spine inversion). -- The detected community is not itself a reason to move. Most candidates sit in a consumer's - community precisely because they are shared foundations; the table records where the module - **can** live, which is often where it already lives. -- Moves only count when the target has a real seam: no new dependency direction, no root - composition pulled into a package, and the file's own imports come along unchanged. Where the - proposal was infeasible, the row records that, and no issue is filed to re-propose it. -- A path-keyed rule (R13's importer lists, R76's inventory, R78's scope) describes today's seam, - not proof that a placement is best. A row whose only evidence is such a rule says - "retain for this batch — relocation requires coordinated ownership changes" instead of resting - the placement on the rule's path. A package cycle rejects the proposed direction, not every - future design that first removes the edge closing it. -- Rows marked `move (batch N)` are tracked as unchecked items on #3281; batch 1 is this decision's - companion `refactor(move)` PR. +- A `keep` row is a decision, not a deferral. + Its rationale identifies the examined direction and why it fails, usually a package cycle or spine inversion. +- A detected community does not independently justify a move. + Most candidates belong to a consumer's detected community because they are shared foundations. + The table records valid ownership locations, often the existing locations. +- A move counts only when the target has a real seam. + A real seam means that the move adds no new dependency direction, pulls no root composition into a package, and keeps the file's own imports unchanged. + If a proposal was infeasible, the row records that. + No issue is filed to propose it again. +- A path-keyed rule, for example R13's importer lists, R76's path-family ban, or R78's scope, describes today's seam. + It does not prove that a placement is best. + If a row has only path evidence, mark it "retain for this batch — relocation requires coordinated ownership changes". + A package cycle rejects the proposed direction. + It does not prohibit a future design that first removes the cycle-closing edge. ## Decision table @@ -35,197 +37,197 @@ longer bind; re-evaluate those files on their remaining edges. | File | Decision | Reason | | --- | --- | --- | -| `src/capture-admission/audio-probe-admission-ledger.ts` | keep (proposed move rejected) | The proposed → managed-allocation move would add a capture-kit→managed-allocation edge while `managed-allocation` already depends on `capture-kit`, completing a package cycle (R4). That rejects this direction; a future design that first removes the edge closing the cycle (e.g. moving the record codec off capture-kit) is not excluded by this row. | -| `src/capture-admission/durable-capture-admission-ledger.ts` | keep (proposed move rejected) | Same rejected direction: it is the generic engine the three per-resource ledgers specialize, consumed by `durable-capture-resource.ts` and `durable-capture-start-preflight.ts` inside capture-kit. | -| `src/capture-admission/perf-capture-admission-ledger.ts` | keep (proposed move rejected) | Same rejected direction; a four-line specialization of the ledger its own package owns. | -| `src/capture-admission/screen-recording-admission-ledger.ts` | keep (proposed move rejected) | Same rejected direction; consumed by capture-kit's screen-recording session resource and stop-recovery. | -| `src/durable-json.ts` | keep (proposed move rejected) | `managed-allocation` already imports this module from capture-kit, so the proposed direction cannot reverse without completing a package cycle; a design that removes that import first stays open. Considered a contracts home beside `json.ts` and rejected on ownership grounds: contracts is wire vocabulary (R18), not durable-descriptor validation mechanics. The coupling the community saw is the three managed-allocation `record-*` consumers; they can stay. | -| `src/snapshot/snapshot-evidence.ts` | keep | Snapshot-capture mechanics; the daemon-dominated community is consumer-side, and moving it into daemon-server would force every platform and provider onto the daemon zone for capture evidence. | -| `src/snapshot/snapshot-freshness/index.ts` | keep | Same: platform-neutral freshness policy its consumers (capture-kit itself, platforms, daemon) all import downward today. | -| `src/snapshot/touch-reference-frame.ts` | keep | Snapshot-geometry mechanics used by interaction and capture alike; capture-kit is the only zone below both. | +| `src/capture-admission/audio-probe-admission-ledger.ts` | keep (proposed move rejected) | The proposed move to managed-allocation would add a capture-kit→managed-allocation edge. The reverse dependency already exists, so this forms an R4 package cycle. This rejects the direction, not a future design that first removes the cycle-closing edge. Moving the record codec off capture-kit is one example. | +| `src/capture-admission/durable-capture-admission-ledger.ts` | keep (proposed move rejected) | The same direction is rejected. This generic engine serves three per-resource ledgers. Capture-kit's `durable-capture-resource.ts` and `durable-capture-start-preflight.ts` consume it. | +| `src/capture-admission/perf-capture-admission-ledger.ts` | keep (proposed move rejected) | The same direction is rejected. This file is a four-line specialization of the ledger that its own package owns. | +| `src/capture-admission/screen-recording-admission-ledger.ts` | keep (proposed move rejected) | The same direction is rejected. Capture-kit's screen-recording session resource and stop-recovery consume it. | +| `src/durable-json.ts` | keep (proposed move rejected) | Managed-allocation already imports this capture-kit module. Reversal forms a package cycle unless that import disappears first. A design that first removes that import stays open. Contracts beside `json.ts` was also rejected: under R18, contracts holds wire vocabulary, not durable-descriptor validation mechanics. Three managed-allocation `record-*` consumers explain the community coupling and can remain. | +| `src/snapshot/snapshot-evidence.ts` | keep | This is snapshot-capture mechanics. The daemon-dominated community represents consumers. A daemon-server home would force every platform and provider to depend on the daemon zone for capture evidence. | +| `src/snapshot/snapshot-freshness/index.ts` | keep | The reason for the row above applies. This is platform-neutral freshness policy. All its consumers, capture-kit itself, platforms, and daemon, import it downward today. | +| `src/snapshot/touch-reference-frame.ts` | keep | Interaction and capture share these snapshot-geometry mechanics. Only capture-kit is below both. | ### contracts -contracts is the rank-1 shared-vocabulary zone. Each candidate below that stays has dependents in -two or more higher-ranked zones, so no single consumer zone can own it without inverting the spine -(R5). The one move row is the counter-case: its dependents all sit in one consumer zone that -already owns the operations behind the vocabulary. +Contracts is the rank-1 zone for shared vocabulary. +Each retained candidate has consumers in at least two higher-ranked zones. +A single consumer zone cannot own it without an R5 spine inversion. +The move row is the exception. +All its consumers occupy one zone that already owns the operations represented by its vocabulary. | File | Decision | Reason | | --- | --- | --- | -| `src/android-observation.ts` | keep | Vocabulary read by capture-kit, platform-android, and daemon-server; contracts is the only zone below all three. | -| `src/android-system-chrome.ts` | keep | Same three-way split (capture-kit, platform-android, daemon-server). | -| `src/app-events.ts` | keep | Event vocabulary shared by commands, daemon, and platforms. | -| `src/app-state.ts` | keep | Broad fan-in app-state vocabulary; any consumer-side home would rank it above the others. | -| `src/device-boot.ts` | keep | Boot-phase vocabulary for device-selection, platforms, and daemon-server. | -| `src/facades/device.ts` | keep | Published facade of the contracts package; facades are the package boundary, not movable content. | -| `src/interaction-guarantees.ts` | keep | AGENTS.md names this file as the declaration site for interaction dispatch paths and guarantee cells (ADR 0011); the cross-language golden tables under `contracts/fixtures/` bind it to this package. | -| `src/managed-device-allocation.ts` | move (batch 1) → `packages/managed-allocation` | The port and its vocabulary ([ADR 0021](0021-host-simlock-managed-device-allocation.md) §3) are consumed only by managed-allocation and the daemon composition that already depends on that package; no other contracts file or zone imports it, so the types land beside the operations that implement them. | -| `src/wait-runtime-plan.ts` | keep | Shared by command-registry and daemon-server; both are spine zones and contracts is the only seam below them. | +| `src/android-observation.ts` | keep | Capture-kit, platform-android, and daemon-server read this vocabulary. Only contracts is below all three. | +| `src/android-system-chrome.ts` | keep | Capture-kit, platform-android, and daemon-server share this vocabulary. | +| `src/app-events.ts` | keep | Commands, daemon, and platforms share this event vocabulary. | +| `src/app-state.ts` | keep | Many zones consume this app-state vocabulary. Any consumer-side home would place it above other consumers. | +| `src/device-boot.ts` | keep | Device-selection, platforms, and daemon-server consume this boot-phase vocabulary. | +| `src/facades/device.ts` | keep | This is the published facade of the contracts package. Facades are the package boundary, not movable content. | +| `src/interaction-guarantees.ts` | keep | AGENTS.md names this declaration site for interaction dispatch paths and guarantee cells under ADR 0011. Cross-language golden tables in `contracts/fixtures/` bind it to this package. | +| `src/managed-device-allocation.ts` | move (batch 1, landed in #3287; package removed 2026-10-10) → `packages/managed-allocation` | Only managed-allocation and its dependent daemon composition consume this port and vocabulary; see [ADR 0021](0021-host-simlock-managed-device-allocation.md) §3. No other contracts file or zone imports it. Types therefore belong beside their implementing operations. | +| `src/wait-runtime-plan.ts` | keep | Command-registry and daemon-server share it. Both are spine zones; contracts is the only seam below them. | ### device-selection | File | Decision | Reason | | --- | --- | --- | -| `src/device-inventory-context.ts` | keep | Rank-1 selection engine imported by daemon-server, remote, and replay adapters; the daemon-heavy community is its largest consumer, not its owner. | -| `src/device-selection-resolver.ts` | keep | Same — moving it up would rank it above its other consumers (replay-port, cli). | -| `src/dispatch-resolve.ts` | keep | Dispatch resolution is shared by the daemon gateway and the daemonless CLI paths; only the rank-1 zone sits below both. | -| `src/open-target.ts` | keep | Open-target vocabulary consumed across daemon, remote, and CLI. | +| `src/device-inventory-context.ts` | keep | Daemon-server, remote, and replay adapters import this rank-1 selection engine. The daemon-heavy community is its largest consumer, not its owner. | +| `src/device-selection-resolver.ts` | keep | A higher-ranked home would place it above its other replay-port and cli consumers. | +| `src/dispatch-resolve.ts` | keep | The daemon gateway and daemonless CLI paths share dispatch resolution. Only the rank-1 zone is below both. | +| `src/open-target.ts` | keep | Daemon, remote, and CLI consume this open-target vocabulary. | ### host-kit | File | Decision | Reason | | --- | --- | --- | -| `src/diagnostics.ts` | keep | The diagnostics seam every zone imports (AGENTS.md mandates it); highest fan-in in the repo is what a substrate looks like, not misplacement. | -| `src/internal/request-cancel.ts` | keep | Request-cancellation mechanics AGENTS.md routes to `host-kit/request`; internal beside its facade is already the collocation. | -| `src/request.ts` | keep | Cross-layer request contracts AGENTS.md names as a host-kit seam; consumers span every zone. | -| `src/session-paths.ts` | keep | Session-artifact path grammar the daemon, cli, and platforms all resolve; AGENTS.md keeps the path *policy* in `packages/daemon-contracts/src/session-artifact-paths.ts`, which consumes this. | +| `src/diagnostics.ts` | keep | This is the diagnostics seam that every zone imports, as AGENTS.md mandates. Its repository-highest consumer count reflects a shared substrate, not incorrect placement. | +| `src/internal/request-cancel.ts` | keep | AGENTS.md routes request cancellation through `host-kit/request`. Its internal mechanics already reside beside the facade. | +| `src/request.ts` | keep | AGENTS.md names these cross-layer request contracts as a host-kit seam. Consumers span every zone. | +| `src/session-paths.ts` | keep | Daemon, cli, and platforms resolve this session-artifact path grammar. AGENTS.md assigns path policy to `packages/daemon-contracts/src/session-artifact-paths.ts`, which consumes it. | ### kernel | File | Decision | Reason | | --- | --- | --- | -| `src/device-isolation.ts` | keep | Device-isolation vocabulary below device-selection, replay-port, and daemon; kernel is the only zone beneath all of them. | +| `src/device-isolation.ts` | keep | This device-isolation vocabulary is below device-selection, replay-port, and daemon. Kernel is the only zone below all of them. | ### platform-android | File | Decision | Reason | | --- | --- | --- | -| `src/device-boot.ts` | keep | Boot mechanics the platform owns per ADR 0019/R13; the root-zone consumers are the composition root loading its own platform packages. | +| `src/device-boot.ts` | keep | ADR 0019/R13 assign boot mechanics to the platform. The root-zone consumers are the composition root, which loads its own platform packages. | ### platform-apple | File | Decision | Reason | | --- | --- | --- | -| `src/runner-owner-facade.ts` | keep | ADR 0005 runner-owner seam; R13 owns the platform package's composition exports and the root importer is the designated host. | -| `src/runner/legacy-xctest-device-set.ts` | keep | Runner-internal legacy device-set parsing beside the runner it serves. | -| `src/simulator-boot.ts` | keep | Simulator boot mechanics are platform authority (ADR 0009); the kernel device model and xctestrun preparation must stay in sync with it in this package. | +| `src/runner-owner-facade.ts` | keep | This is the ADR 0005 runner-owner seam. R13 owns the platform package's composition exports. The root importer is the designated host. | +| `src/runner/legacy-xctest-device-set.ts` | keep | Runner-internal legacy device-set parsers reside beside their runner. | +| `src/simulator-boot.ts` | keep | ADR 0009 assigns simulator boot mechanics to the platform. The kernel device model and xctestrun preparation must remain synchronized with them in this package. | ### replay-port | File | Decision | Reason | | --- | --- | --- | -| `src/daemon-port/session-test-shard-devices.ts` | keep | Already collocated: it is the daemon-port adapter binding shard enumeration, and `replay-test` (the scheduler) deliberately never enumerates hardware — moving it toward that zone would break the port. | +| `src/daemon-port/session-test-shard-devices.ts` | keep | This file is already collocated. It is the daemon-port adapter that binds shard enumeration. The `replay-test` scheduler deliberately never enumerates hardware. A move toward that zone would break the port. | ### selectors | File | Decision | Reason | | --- | --- | --- | -| `src/parameterized-recorded-fill.ts` | keep | ADR 0017 recorded-input mechanics; selector parsing/matching is centralized in this package by AGENTS.md. | -| `src/target-evidence.ts` | keep | Target-evidence vocabulary shared by daemon replay and selectors matching; selectors is below both. | +| `src/parameterized-recorded-fill.ts` | keep | These are ADR 0017 recorded-input mechanics. AGENTS.md centralizes selector parsers and matchers in this package. | +| `src/target-evidence.ts` | keep | Daemon replay and selector matchers share this target-evidence vocabulary. Selectors is below both. | ### cli | File | Decision | Reason | | --- | --- | --- | -| `src/cli/commands/device-release.ts` | moved | Now `src/platform-runtime-claim-recovery.ts`: the daemonless stale release composes the shared reconciler with the root gateway, and the CLI process may reach no daemon module (R78), so it sits beside the claim-recovery gateway in the platform-runtime composition. | +| `src/cli/commands/device-release.ts` | moved | Its current home is `src/platform-runtime-claim-recovery.ts`. Daemonless stale release composes the shared reconciler with the root gateway. R78 prohibits CLI access to daemon modules. It therefore resides beside the claim-recovery gateway in platform-runtime composition. | ### (root) -`#3288` assigns every process-root module a logical zone in -`scripts/layering/root-module-zones.ts` (`command-runtime` rank 3, -`platform-runtime` rank 4, …) while leaving the files physically in place. Per the maintainer -design decision, that reclassification is **not** completed collocation: the physical moves under -#3294 (child of #3276) replace each file's row with folder-derived ownership. -The runtime assembly pair -`runtime-command-surface.ts` / `runtime-factory.ts` has moved into `src/command-runtime/`, and -the whole `daemon-contracts` zone has moved into `packages/daemon-contracts` — the folder and -package derive the zone, so every moved file's `ROOT_MODULE_ZONES` row is gone. -The daemon ⇄ client shared files that remain here (`provider-credential-fingerprint.ts`) trace -to #2559, which relocated the shared contracts **to the process root** so the client stops -importing `packages/daemon/src/` at all; the `client-side` logical-module policy bans every edge from the CLI, typed client, remote transports and -`src/daemon-client/` into `packages/daemon/src/`, R78 bans the hop through any root module the CLI -process reaches, and #3288's `daemon-contracts` -zone was the declaration of that shared-below-both ownership — the extraction makes it -physical, with R11 and the package's dependency edges holding the same boundary. Rows on -path-keyed evidence alone (the `platform-runtime-*` seams) say "retain for this batch": a guard's -current path lists constrain a relocation, they are not independent proof the placement is -optimal. +#3288 assigns each process-root module a logical zone through `scripts/layering/root-module-zones.ts` without moving files. +Examples are `command-runtime` at rank 3 and `platform-runtime` at rank 4. +The maintainer decision does not classify this as completed collocation. +Physical moves under #3294, a #3276 child, replace per-file declarations with folder-derived ownership. + +`runtime-command-surface.ts` and `runtime-factory.ts` now reside in `src/command-runtime/`. +The complete `daemon-contracts` zone resides in `packages/daemon-contracts`, behind narrow per-file subpath exports. +Folders and packages derive these zones, so moved files no longer need `ROOT_MODULE_ZONES` rows. + +The remaining shared daemon/client file, `provider-credential-fingerprint.ts`, follows #2559's decision. +That decision moved shared contracts **to the process root** so that the client stops all imports of `packages/daemon/src/`. +The `client-side` logical-module policy prohibits every edge into `packages/daemon/src/` from these consumers: + +- CLI. +- Typed client. +- Remote transports. +- `src/daemon-client/`. + +R78 also prohibits access through any root module reachable by the CLI process. +#3288's `daemon-contracts` zone declared ownership below both daemon and client. +Package extraction makes it physical. +R11 and package dependencies preserve that boundary. +Rows supported only by path-keyed evidence, the `platform-runtime-*` seams, say "retain for this batch". +A guard's current path lists constrain a relocation. +They are not independent proof that the placement is optimal. | File | Decision | Reason | | --- | --- | --- | -| `src/daemon-diagnostics-scope.ts` | moved → `packages/daemon-contracts/src/daemon-diagnostics-scope.ts` ([#3297](https://github.com/callstack/agent-device/pull/3297) into `src/daemon-contracts/`, then the package extraction) | The whole `daemon-contracts` zone now lives as `packages/daemon-contracts`, so the package directory derives the zone and the per-file `ROOT_MODULE_ZONES` row stays deleted; an internal rename needs no ownership-table edit. | -| `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped claim reconciler runs durable-resource recovery for a dead owner's state dir; the daemon runs it at `open` and startup and the daemonless `device release --stale` runs it too, so it lives below both. Runtime recovery may live in `daemon-contracts` only when both sides run it and root composition supplies just the gateway (ADR 0019 section 1); the package depends on `capture-kit`, which cannot depend back on it (a project-reference cycle through `selectors`). | -| `src/daemon-policy-file.ts` | moved → `packages/daemon-contracts/src/daemon-policy-file.ts` | Daemon ⇄ client shared contract from #2559, declared `daemon-contracts` by #3288 — the shared-below-both ownership the spine needs, since daemon-client (rank 5) dynamically imports it. The extraction makes that placement physical: the package sits below both sides and its manifest exports own the boundary. | -| `src/daemon.ts` | keep | Entry point (`internal/daemon` bundle entry); composition roots stay in root by umbrella §4. | -| `src/platform-runtime-apple-runner-owner.ts` | retain for this batch | `platform-runtime-*` composition seam: R13's exact-importer rule and the ADR 0022 R76 inventory key on its path, so relocation requires coordinated ownership changes to both declarations — the rules constrain the move, they do not independently prove the placement. | -| `src/platform-runtime-daemon-lifecycle.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam: relocation is possible only with coordinated changes to those declarations. | -| `src/platform-runtime-device-boot.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam. | -| `src/platform-runtime-resource-cleanup.ts` | retain for this batch | Same path-keyed R13/ADR 0022 seam. | -| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Daemon ⇄ client shared contract from the same #2559 decision as `daemon-policy-file.ts`; #3288 declares its zone beside the provider composition that reads the credentials it fingerprints. PR 6 of the daemon extraction moved only the `DaemonProviderCredentials` declaration to `packages/daemon-contracts` (`./provider-credentials`), the same shared-below-both landing as the policy file; the fingerprinting implementation stays here because it reads the installed-plugin manifest. | -| `src/provider-limrun-runtime.ts` | keep | Proposed → provider-limrun, but there is no seam: the class's constructor self-builds the root dependency factory (`src/sdk/limrun-runtime-dependencies.ts`), which is the ADR 0019 composition seam — it loads the root's adb-host binder, core Android interactor, and platform-runtime app-state helpers that the package must not import. Moving the class means moving that composition, which is design, not a move. | -| `src/request-progress-protocol.ts` | moved → `packages/daemon-contracts/src/request-progress-protocol.ts` | Daemon ⇄ client shared contract from the same #2559 decision — the client reads it statically (`daemon-client-progress.ts`) and the server through `packages/daemon/src/server/`, so only #2559's shared-placement boundary and the wire-compat ledgers pin it; declared `daemon-contracts` by #3288 and now physically placed by the extraction. Contracts was considered and R18 keeps contracts free of envelope validation mechanics. The wire-ledger keys moved with the file. | -| `src/runtime-command-surface.ts` | moved → `src/command-runtime/runtime-command-surface.ts` ([#3299](https://github.com/callstack/agent-device/pull/3299)) | Physical move landed with `runtime-factory.ts` as one assembly group: the command-surface binding sits beside the factory it composes, under the folder that derives its `command-runtime` zone, so `topFolder` replaces the per-file `ROOT_MODULE_ZONES` row. | -| `src/runtime-factory.ts` | moved → `src/command-runtime/runtime-factory.ts` ([#3299](https://github.com/callstack/agent-device/pull/3299)) | Physical move landed: the runtime assembly now lives under the folder that derives its `command-runtime` zone, with the same row deletion through folder-derived ownership. | +| `src/daemon-diagnostics-scope.ts` | moved → `packages/daemon-contracts/src/daemon-diagnostics-scope.ts` ([#3297](https://github.com/callstack/agent-device/pull/3297) into `src/daemon-contracts/`, then the package extraction) | The complete zone resides in `packages/daemon-contracts`. Its directory derives zone ownership without per-file `ROOT_MODULE_ZONES` rows. An internal rename therefore needs no ownership-table edit. | +| `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped claim reconciler recovers durable resources from a dead owner's state directory. Daemon `open`/startup and daemonless `device release --stale` both run it. It therefore belongs below both. Runtime recovery may reside in `daemon-contracts` only when both sides run it and root composition supplies only the gateway, per ADR 0019 section 1. The package depends on `capture-kit`. Capture-kit cannot depend back on it, because that dependency forms a project-reference cycle through `selectors`. | +| `src/daemon-policy-file.ts` | moved → `packages/daemon-contracts/src/daemon-policy-file.ts` | This shared daemon/client contract follows #2559 and #3288's `daemon-contracts` declaration. Rank-5 daemon-client dynamically imports it, so the spine requires a home below both. Package extraction supplies that physical home with manifest-owned exports. | +| `src/daemon.ts` | keep | This is the `internal/daemon` bundle entry. Umbrella §4 keeps composition roots at the root. | +| `src/platform-runtime-apple-runner-owner.ts` | retain for this batch | This is a `platform-runtime-*` composition seam. R13's exact-importer rule and ADR 0022's R76 ban on the `src/platform-runtime-*.ts` family key on its path. Relocation requires coordinated ownership changes to both declarations. The rules constrain the move without independently proving placement. | +| `src/platform-runtime-daemon-lifecycle.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. Relocation is possible only with coordinated changes to those declarations. | +| `src/platform-runtime-device-boot.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. | +| `src/platform-runtime-resource-cleanup.ts` | retain for this batch | The same path-keyed R13/ADR 0022 seam applies. | +| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Like `daemon-policy-file.ts`, this shared daemon/client contract follows #2559. #3288 declares its zone beside the provider composition that reads the credentials this file fingerprints. Daemon extraction PR 6 moved only the `DaemonProviderCredentials` declaration to `packages/daemon-contracts` at `./provider-credentials`. This is the same shared-below-both landing as the policy file. The implementation remains here because it reads the installed-plugin manifest. | +| `src/provider-limrun-runtime.ts` | keep | The proposed provider-limrun destination has no seam. Its constructor builds the root dependency factory `src/sdk/limrun-runtime-dependencies.ts`, which is the ADR 0019 composition seam. This loads the root adb-host binder, core Android interactor, and platform-runtime app-state helpers, all prohibited package imports. Moving the class also moves that composition. That is a design change, not a move. | +| `src/request-progress-protocol.ts` | moved → `packages/daemon-contracts/src/request-progress-protocol.ts` | This follows #2559's shared-contract decision. The client reads it statically through `daemon-client-progress.ts`; the server reads it through `packages/daemon/src/server/`. Only shared placement and wire-compat ledgers constrain it. #3288 declared `daemon-contracts`; extraction made it physical and moved wire-ledger keys with it. Contracts was rejected because R18 excludes envelope validation mechanics. | +| `src/runtime-command-surface.ts` | moved → `src/command-runtime/runtime-command-surface.ts` ([#3299](https://github.com/callstack/agent-device/pull/3299)) | The command-surface binding resides beside `runtime-factory.ts` as one assembly group. Their folder derives `command-runtime` ownership. `topFolder` replaces per-file `ROOT_MODULE_ZONES` declarations. | +| `src/runtime-factory.ts` | moved → `src/command-runtime/runtime-factory.ts` ([#3299](https://github.com/callstack/agent-device/pull/3299)) | The runtime assembly folder derives `command-runtime` ownership without per-file `ROOT_MODULE_ZONES` declarations. | ### sdk | File | Decision | Reason | | --- | --- | --- | -| `src/sdk/limrun.ts` | keep | The published `agent-device/limrun` facade: `tsdown.config.ts` builds it as the bundle entry and `package.json#exports` ships `dist/src/limrun.js`. A published facade cannot live in a private workspace package. | -| `src/sdk/limrun-runtime-types.ts` | keep | The public session-type refinement (provider session with the platform `AndroidAdbProvider` types swapped in) exists precisely because the published surface differs from the package-internal one, whose identical names it `Omit`s. Relocating it into provider-limrun requires renaming or dual-exporting the internal types — design, not a move. | +| `src/sdk/limrun.ts` | keep | This file is the published `agent-device/limrun` facade. `tsdown.config.ts` builds it as the bundle entry, and `package.json#exports` ships `dist/src/limrun.js`. A published facade cannot reside in a private workspace package. | +| `src/sdk/limrun-runtime-types.ts` | keep | This public session-type refinement is the provider session with the platform `AndroidAdbProvider` types swapped in. It exists because the published surface differs from the package-internal surface, whose identical names it `Omit`s. A provider-limrun home requires renamed or dual-exported internal types. That is a design change, not a move. | ## Batch 1 (this decision's companion PR) -- `packages/contracts/src/managed-device-allocation.ts` → `packages/managed-allocation/src/managed-device-allocation.ts` - (new `@agent-device/managed-allocation/managed-device-allocation` subpath). - -Batch 1 is exactly this one file. The moves the issue listed beside it failed re-verification -against the current import graph, and the rows above record why: the four admission ledgers and -`durable-json.ts` hit the capture-kit ⇄ managed-allocation package cycle, and the three limrun -files have no seam (published facade, public-type collision, root dependency factory). +Git history retains the completed batch. ## Follow-ups (tracked on #3281) -- Physical root-pass moves under [#3294](https://github.com/callstack/agent-device/issues/3294) - (child of #3276): the runtime assembly pair - `runtime-command-surface.ts` / `runtime-factory.ts` is collocated in `src/command-runtime/` - ([#3299](https://github.com/callstack/agent-device/pull/3299)). Each folder derives its zone, - replacing the moved files' `ROOT_MODULE_ZONES` rows; #3288's zone assignments alone were - classification, and each landed move completes its own file's collocation. The - `daemon-contracts` side landed as a workspace package: the zone's ten files (`daemon-owner-cleanup`, - `daemon-policy-file`, `daemon-process`, `daemon-registration-owner`, `daemon-registration`, - `daemon-resolution`, `daemon-shutdown-report`, `request-progress-protocol`, - `session-repair-tombstone`, and `daemon-diagnostics-scope` from `src/daemon-contracts/`, first - moved there by [#3297](https://github.com/callstack/agent-device/pull/3297)) are now - `packages/daemon-contracts/src/` behind narrow per-file subpath exports. +Physical root-pass moves continue under [#3294](https://github.com/callstack/agent-device/issues/3294) (child of #3276). +Git history retains completed move records. +The decision table retains ownership rationale. ## The daemon package's export surface -`@agent-device/daemon` has one entry, `.`, which is `packages/daemon/src/index.ts`: explicit named -re-exports of what the daemon process root uses (`startDaemonRuntime` and the types its -composition and platform-runtime modules name), no `export *`. It is the package boundary, the -one place a barrel is allowed; the layer folders hold no barrel. A value import of the package -evaluates the daemon server graph, so root code that needs only command metadata reads it from -its owner: the route owner-file claims sit beside the per-command claims in -`@agent-device/command-registry/owner-files`, and the wire request and response types come from -`@agent-device/contracts/command` and `@agent-device/kernel/contracts`. - -The transitional `./testing/` entries are gone, last of them behind the consumers that moved off -them in [#3728](https://github.com/callstack/agent-device/pull/3728), +`@agent-device/daemon` has one entry, `.`, at `packages/daemon/src/index.ts`. +It explicitly re-exports, by name, what the daemon process root uses. +These are `startDaemonRuntime` and the types that the root's composition and platform-runtime modules name. +It has no `export *`. +This package boundary is the only permitted barrel. +Layer folders contain no barrels. + +A package value import evaluates the daemon server graph. +Root code that needs only command metadata therefore reads its owner directly. +The route owner-file claims reside beside the per-command claims in `@agent-device/command-registry/owner-files`. +Wire request and response types come from `@agent-device/contracts/command` and `@agent-device/kernel/contracts`. + +The transitional `./testing/` entries are gone. +The last of them went behind the consumers that moved off them in +[#3728](https://github.com/callstack/agent-device/pull/3728), [#3733](https://github.com/callstack/agent-device/pull/3733), and [#3742](https://github.com/callstack/agent-device/pull/3742). -`scripts/layering/package-entry-surface.ts` owns the surface under R83, so this document states the -decision and not the check. Package tests import their subject relatively; composed root tests reach -the daemon through `startDaemonRuntime` from the entry. +`scripts/layering/package-entry-surface.ts` owns the surface under R83. +This document states the decision, not the check. +Package tests import their subject relatively. +Composed root tests reach the daemon through `startDaemonRuntime` from the entry. ## Refuted alternatives -- **Move every ≥80% community match into its dominant zone.** Rejected: the measure is - consumer-side. Foundations (host-kit, contracts, selectors, device-selection) score badly by - construction, and reversing their edges inverts the spine. -- **`durable-json.ts` → contracts.** Pure-JSON helpers looked like `contracts/json.ts` neighbors, - but R18 holds contracts to wire vocabulary, and these functions are durable-descriptor - persistence validation. Rejected. -- **Ledgers → managed-allocation with capture-kit importing them back.** Rejected as proposed: it - completes the cycle that R4/R11 reject while managed-allocation's dependency on capture-kit is - load-bearing (its record codec uses capture-kit durable mechanics). Removing that load-bearing - import first would reopen the question — this row refuses the sequence, not the destination. +- **Move every ≥80% community match into its dominant zone: rejected.** + The measure describes consumers. + Host-kit, contracts, selectors, and device-selection are shared foundations and score poorly by construction. + Reversed dependencies invert the spine. +- **`durable-json.ts` → contracts: rejected.** + Pure-JSON helpers appear similar to `contracts/json.ts`. + However, they validate durable-descriptor persistence, while R18 restricts contracts to wire vocabulary. +- **Ledgers → managed-allocation, with capture-kit importing them back: rejected as proposed.** + Managed-allocation's record codec requires capture-kit durable mechanics. + The reverse edge completes a cycle prohibited by R4/R11. + Removal of the required import first would permit reconsideration. + This entry rejects the sequence, not the destination. ## The enduring rule -Collocation is judged by ownership: the placement that minimizes independently maintained -declarations per change. Dependency-community scores only identify candidates; a guard's current -path lists (R13, R76, R78) are constraints a relocation must account for, never independent -evidence that a location must stay. A keep that cannot cite ownership, only a path, is marked -"retain for this batch" so the next pass re-opens it rather than inheriting it as settled. +Judge collocation by ownership: the location that minimizes independently maintained declarations per change. +Community scores identify candidates only. +A guard's current path lists, for example R13, R76, and R78, are constraints that a relocation must account for. +They are never independent evidence that a location must stay. +If only a path supports retention, mark the row "retain for this batch". +The next pass then re-opens it instead of treating it as settled.