From 67317dd5d3070050c82c32bde4ca76b2e254edb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:52:37 +0200 Subject: [PATCH 01/52] docs(adr): delete completed ADR 0019 checkpoint history --- .../0019-request-bound-platform-runtime.md | 204 +----------------- 1 file changed, 2 insertions(+), 202 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 41f82f85ba..6598ff21b0 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -2,10 +2,7 @@ ## 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. +Completed (2026-08-27). Accepted on 2026-08-09. Completed process history is retained in git history. 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 @@ -72,9 +69,6 @@ recreate the old root path. 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 @@ -573,204 +567,10 @@ shrinks the cycle lowers the zone ceilings in the same change rather than leavin ### 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. +The completed checkpoint procedures and results are retained in git history. ### 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 From 4b903b67a1cf4c3a6867473ea3d84e83f8149982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:56:28 +0200 Subject: [PATCH 02/52] docs(adr): write ADR 0019 in ASD-STE100 --- .../0019-request-bound-platform-runtime.md | 1652 +++++++++-------- 1 file changed, 915 insertions(+), 737 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 6598ff21b0..511f8b7424 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -2,811 +2,989 @@ ## Status -Completed (2026-08-27). Accepted on 2026-08-09. Completed process history is retained in git history. - -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 on 2026-08-09. Git history retains the completed process history. + +The amendment of 2026-08-26 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 use this execution category. +None binds a request-scoped device runtime. Their domain services remain separate. + +An amendment proposed on 2026-09-23 accompanies the web press-shape refusal. +It would state in section 3 that a modifier on an admitted operation is not a second operation and has no fact cell. +A per-shape refusal would belong to the owning leaf mechanic. +The operation-facts mapped type cannot express a per-shape fact 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 through #2070–#2072 removes 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/**`. +R66 enforces facts-only admission and runtime proof without historical route or handler names. +Section 6 required deletion of the completed cutover table. + +The #2082 extraction puts all six families' implementations and family-owned tests behind private package exports. +The `src/platforms` path and former R3 folder boundary are retired. +R13 governs concrete platform imports and implementation laziness. +The `retired-platforms-zone` gate rejects 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. - 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. -- 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. +- 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. + Only root composition and R13-governed named consumer facades statically import production platform values. +- The registry is **metadata-eager and implementation-lazy**. + Cheap family identity, inventory entrypoints, and static facts may load at composition time. + Mechanics and process-lived helper managers load only when discovery or the first family binding needs them. +- Each canonical family has one local runtime owner. + 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. +- Descriptors declare typed inventory use or platform-neutral required device operations. + They declare preferred optimizations and fact-conditional semantic operations separately. + Runtime owners report device-specific facts and expose behavior through facets. + Platform and provider implementations never name commands. +- `RequestExecutionScope.bindDevice(device, use)` resolves ownership, validates facts and 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 authoritative and fail-closed. + A provider may supply platform transport or implement facets directly. + Missing required behavior never causes a local-owner fallback. +- Process-lived helper managers, request-lived 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 this boundary. +- Each descriptor explicitly declares exactly one shape: `none`, `host`, `inventory`, or `device-runtime`. + `none` declares no platform execution. + `host` uses 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. +- Request-scoped device units prove facts, operations, and parity cells. + Only durable-resource units require 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. Conditional operations require parity evidence for correctness that depends on owner facts. +- A cross-cutting facet accompanies its first consuming command unit. + Evidence controls startup recovery. 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 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. +This migration limited new platform conditionals. +However, app-log, recording, performance, and provider facets still return tags or predicates that the daemon maps to concrete implementations. + +The registry has six canonical families: + +- `apple` +- `android` +- `harmonyos` +- `vega` +- `linux` +- `web` + +HarmonyOS supplied the additional adapter requirement that the earlier platform-package checkpoint deferred. +A tag-to-implementation map leaves the daemon responsible for every platform variation, even with the conditional behind a plugin. -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 replacement must preserve these architectural properties: -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. +- 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 boundaries 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 boundary. +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 layout. > -> - `@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. +> Only a port can reach modules under `src/internal/`. +> A port may contain only mechanics that a consumer of that capability needs. +> The eager-closure row for each port enforces this restriction. +> - Helpers that access no process, file, or environment belong to `@agent-device/kernel`, with 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 production, test, and fixture files 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 boundaries. +Daemon and root consumers currently reach runner mechanics directly through `./runner`. +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. +Exactly one composition root constructs the injected `AppleRunnerHost` port. +It 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` boundary 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 a 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. +- 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. -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. +Composition may eagerly capture inert configured paths. +These activities remain lazy: -### 2. Runtime use joins facts and narrows the bound runtime - -`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. - -### 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 does not replace the required load structure. +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. + +An owner must provide an advertised conditional operation because its 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`. +Coverage includes all six local family sources, every provider source, and parity for selectors 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. + +**Proposed amendment: a shape of an operation is not an operation and would get no fact cell.** +Runtime facts would describe semantic operations. +A modifier such as a double, held, or repeated press would not create another operation or receive a fact key. +The facts map is a mapped type over operation names. +A phantom operation satisfies neither required nor preferred runtime-use shapes. +An absent owning leaf mechanic would refuse a requested shape with a typed reason and hint. +An absent member for a whole admitted operation would remain an ownership bug with a separate typed, fail-closed reason. +It would not represent an unsupported shape. + +**A subject of an operation may carry its own fact.** +An operation may serve a closed vocabulary with unequal owner support, such as `setSetting` over the settings catalog. +Its available fact may name a fact per subject and an `unlisted` refusal for other subjects. +Before a binding, admission uses the requested subject and refuses unavailability as it does for an 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 authority boundary. +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 exposes undeclared facets. +The descriptor and specialized handler share one declaration without type expansion. +A widened generic descriptor supplies no static proof. +A compile-time contract test proves the selected projection. +The **runtime-facet-narrowing gate** covers every runtime-migrated handler owner. +It rejects assertions and optional admission that manufacture required-operation proof. +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 cannot determine whether the semantic command executes. +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. +Absence is valid only with an unavailable owner fact and parity evidence for that owner's complete required path. +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 command cutover, an independent parity artifact fixes the 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 owner lacks a required fact or facet, admission returns typed unsupported behavior. +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. +The new boundary excludes provider callback stacks through daemon handlers. + +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 an acquisition fails, it releases partial state before rejection. +Publication transfers ownership exactly once to a repository-owned scope disposal stack that operates in reverse order. +Another cached projection never registers or disposes the same binding twice. + +Request execution calls the stack from `finally`. +Source-executed TypeScript does not use `await using`. +The minimum supported Node 22 runtime cannot parse it, type stripping cannot lower it, and Node 22 lacks `AsyncDisposableStack`. +The contract permits a later mechanical syntax migration. +A binding never transfers beyond the request. +Only a durable resource handle from a facet 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 evidence appropriate to its helper; elapsed time alone is insufficient. +Bind scenarios 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. +> The committed export would include the recorder observation and native-path disposition. +> 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 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 guard releases 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 without a valid durable representation. +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 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 no valid manifest exists, introduce a daemon-owned persisted record only for the demonstrated need. +Extract a shared resource index only after multiple resource types require 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`. + +Starting, active, completing, and cleanup-pending are phase metadata on an open record, not additional lifecycle states. +The fence and descriptor remain authoritative across all open phases. +Cleanup uncertainty cannot be 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, an operation atomically enforces the token. +These mechanisms prevent a previous owner from terminating a transferred resource. + +Descriptor persistence does not make external-resource start atomic with descriptor publication. +If a native tool cannot close this crash interval, the platform retains an orphan marker or equivalent recovery protocol. +Descriptors contain stable reconstruction coordinates. +They contain no promises, timers, child handles, buffers, credentials, or complete 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. + +The migration unit was one descriptor across its complete platform/provider denominator. +A temporary parametrized cutover table proved deletion of old adapter, admission, and dispatch projections before merge. +Completion deleted those rows. +The permanent union cannot express `legacy`. +Descriptors cannot carry capability admission. +R66 rejects manufactured runtime proof and restored legacy admission. +R65 rejects daemon dependencies on concrete platform implementations. + +A behavior-neutral package/registry substrate may precede the first command only under all these conditions: + +- It routes no production descriptor. +- It contains no dead code. +- It is independently revertible. +- It preserves lazy loads. + +Otherwise, it accompanies the `devices` command unit. +Migrated and legacy commands do not duplicate shared Apple-runner or Android-helper mechanics. +They do not use package-to-root back-imports. +Mechanics either remain in place until the last consumer can move or use a neutral lower-level capability injected by composition. +Composition injects that capability into both the package facet and complete legacy adapter. +Command execution retains one owner. + +Review the substrate and each command unit from a clean committed tree with all production files in `HEAD`. +A passing layering check with untracked production files is not evidence. +Platform packages receive no migration allowlist or root back-import exception. +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. + +A unit that changes `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. +An opaque implementation behind a neutral facet-specific handle contract is a permitted live value. +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) -The completed checkpoint procedures and results are retained in git history. +Git history retains the completed checkpoint procedures and results. ### 8. Broader migration was move-dominated and evidence-tiered -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 completed checkpoint budgets provide no allowance for future units. +Each unit must justify exceptional growth individually. +Root re-export shims, a broad barrel, or ambient filesystem/process access would reduce source movement. +They would violate package closure, the prohibition on 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. -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. +Completion retired the cutover table. +Facts are the only support authority for device commands. +Permanent R66 policy rejects descriptor capability buckets, `requireCommandSupported` calls, and manufactured or repaired 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 requires its own independent review and parity evidence. +It cannot accompany a structural change incidentally. + +One second bind per request is permitted for a post-action observation (`--settle`) when the action use has no capture (#3510). +The observation admits the `capture-only` plan for its own reads. +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 belongs in the execution use that the plan should select. + +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 declaration justified only by speed belongs under `preferred`. +A 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 violates the boundary. +Section 6 still prohibits a facet PR without a consuming command. + +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 Android test-IME marker recovery and web browser-orphan recovery move to this pattern with their owning domains, 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. + +Daemon runtime no longer calls platform-specific shutdown directly. +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 static, dynamic, re-export, and type-only dependencies 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 boundary. +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 operation it cannot 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 mechanics move only through the permitted injected substrate transition 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 boundary. + 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. +The staged cross-repository migration temporarily maintained two execution systems. +Command-atomic cutover, explicit deletion boundaries, and an early 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 boundary. + 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 in-memory session store already supplies the process-local home. + A parallel handle/descriptor store duplicates ownership governed by the R7 transition owner. + 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 structure. ## 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. +These proposed defaults are not an accepted commitment. +No tracking issue or Status line authorizes work against them. +They provide a numeric baseline for a possible next migration and remain subject to 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. +The file-by-file trace at its 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. +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 permit combination: +`kernel < contracts < host-kit < capture-kit < provision-kit < platform/provider/daemon`. +A hop required by that direction is not unnecessary. +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 combine cross-cutting request-scope wrappers. +Those wrappers are 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 declared host-port adapters or mechanics whose migration is incomplete. + For example, only the named root file binds `adb-host`, 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` depends on root-owned `interaction-targeting` and Android UI-hierarchy fixtures. + - `snapshot-chrome-android-statusbar.test.ts` has those same dependencies. + - `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 boundaries. + - `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. +Both remain inputs to a maintainer decision, not a ratchet. From 1bf28cbf76c190282e94ba35078dff1dd79f2d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:56:53 +0200 Subject: [PATCH 03/52] docs(adr): write ADR 0021 in ASD-STE100 --- ...-host-simlock-managed-device-allocation.md | 112 ++++++++++-------- 1 file changed, 63 insertions(+), 49 deletions(-) diff --git a/docs/adr/0021-host-simlock-managed-device-allocation.md b/docs/adr/0021-host-simlock-managed-device-allocation.md index 6daa2c3dec..99348271bb 100644 --- a/docs/adr/0021-host-simlock-managed-device-allocation.md +++ b/docs/adr/0021-host-simlock-managed-device-allocation.md @@ -2,75 +2,89 @@ ## 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 of 2026-10-10 withdraws the in-process Host allocator integration. +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 through `simlock lease`, the Simlock MCP tool, or its 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. +Its emulators register with Simlock's own adb server. +These boundaries prevent 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 requires only access to devices inside 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 prevent two sessions of one installation from driving the same device. +Simlock's "advisory, not a sandbox" model applies. +agent-device does not verify a device lease. ## 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 uses Simlock's 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. + Agents with leases from different Simlock daemons use separate state directories. + Node SDK `agent-device/android-adb` functions use the caller's adb executor. + The caller configures that executor for 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 before 2026-10-10 and #2284, #2308, and #2312. From 0dd561bf61e6ec5225f69e3974b03ebd0db7bad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:57:06 +0200 Subject: [PATCH 04/52] docs(adr): delete retired ADR 0022 classification procedure --- .../0022-daemon-platform-runtime-coupling.md | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 642f14c031..c429a25e50 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -31,25 +31,10 @@ 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. +1. **R76 bans daemon coupling to root platform-runtime modules.** Any production + `packages/daemon/src/**` import of `src/platform-runtime-*.ts` or a root hub that reaches + that family is a violation, in any static or dynamic form. The completed classification + procedure is retained in git history. The section-2 outcomes retain the audit rationale. 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`; From 07e53691b5681909420a3a775eaf4699be073bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:57:58 +0200 Subject: [PATCH 05/52] docs(adr): write ADR 0022 in ASD-STE100 --- .../0022-daemon-platform-runtime-coupling.md | 255 ++++++++++-------- 1 file changed, 139 insertions(+), 116 deletions(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index c429a25e50..34d9b74f55 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -2,132 +2,155 @@ ## 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. +As its retirement criterion required, R76 became a coupling ban and removed classification machinery. +Section 2 retains the classification outcomes. Git history retains the completed classification procedure. + +## Rules at a glance + +- R76 prohibits production daemon imports of root `src/platform-runtime-*.ts` modules or root hubs that reach them. +- Apple session observation uses neutral `AppleSessionObservation`. +- Platform owners participate through existing lifecycle phases. There is no generic hook collection. +- Open-target plans/results remain separate from platform mechanics, with one construction path. +- R75 permits handler-owned state-shape and store-authority file sets to shrink, but never grow beyond the merge-base. +- Route reduction is limited to hops that the deletion test proves removable. + 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. **R76 bans daemon coupling to root platform-runtime modules.** Any production - `packages/daemon/src/**` import of `src/platform-runtime-*.ts` or a root hub that reaches - that family is a violation, in any static or dynamic form. The completed classification - procedure is retained in git history. The section-2 outcomes retain the audit rationale. - -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 `src/platform-runtime-*.ts` or a root hub that reaches this family 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 remaining nine edges exposed platform mechanics. Their owners 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 retained `resource-cleanup` edge became composition-essential and carried 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 their owner 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. + Live reads use `querySelector` on the request-bound owner. + `SelectorObservationRuntimeOperations` declares it CONDITIONAL on captured touch and gesture uses. + Off-screen rescue (#1542) thus uses the command's admitted binding. + It adds 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. + Classification permits this ratchet. + R7/R10 logical-module declarations own cross-module reads outside this handler path. + R75 does not ratchet those reads. + +4. **The repeated hop trace assigns roles and tests deletion.** + 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`. + Auth comparison, cancellation, idle-reap reset, and Android freshness guard/clear are side-calls, not hops. + The earlier ≤14 target is superseded. + It requires combination of cross-cutting request-scope wrappers, which this audit does not endorse. + +5. **Decisions by audit area.** + - Apple session observation consumes neutral `AppleSessionObservation`. + - Runtime lifecycle participation uses the existing lifecycle phases, without generic hooks (#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 new trace and permits combination only of proven pass-through hops. + This change combines 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 ownership boundary. + 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 admission, sessions, conflicts, locks, cancellation, teardown order, 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 permitted independent review and preserved each consumer's behavior. + The selector edge is deliberately outside #2332. + #2273/#2274 already owned that boundary; 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 regression from #2278: additional handler access to state shape and store authority. + The fresh audit found no owner for this portion. +- **The hop target is retired.** + 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. + Every retained hop now has a documented role. + Each pass-through/translation hop also has a retained-depth or removable verdict. + The one proven removable hop is the only endorsed combination. ## 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. +- Completed child issues #2333, #2334, and #2273/#2274 with the `querySelector` runtime operation; see §2.2. From 595ec2af373ad89f86d340ffecc9d38458e4387f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:58:12 +0200 Subject: [PATCH 06/52] docs(adr): delete completed ADR 0023 revision history --- docs/adr/0023-end-state-hop-trace.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index 2fa6393959..fc2905a0ce 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -5,16 +5,6 @@ Backs the "Entry-to-platform hop count" paragraph in That paragraph cited 38 hops (`press`/Android) and 29 hops (`snapshot`/iOS) with no ordered route, no counting definition, and no linked artifact. This file supplies all three. -The 23/24 tables originally recorded here at `132ffe1da` (corrected for PR #2302 in that PR) -are superseded: the routes moved ~58 commits, `snapshot`/iOS is now a dual-arm route, and the -#2278 audit re-traced both routes file-by-file at HEAD with a role vocabulary. This revision -replaces the tables and keeps the counting definition stable so the numbers remain comparable. -A follow-up revision re-traced the request spine: the auth token comparison, the request -cancellation gate, the idle-reap timer reset, and the Android freshness guard/clear are -side-calls (guards and liveness bookkeeping), not the call that continues toward the platform -boundary, so they are not hops. The tables and every derived number below are recomputed under -that reading. - ## Counting definition - **Unit**: one distinct production `.ts` file entered on the call path, counted once even if From 7698c0e04e0cf2820e43a70a83f42a0ba4038381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 16:59:32 +0200 Subject: [PATCH 07/52] docs(adr): write ADR 0023 in ASD-STE100 --- docs/adr/0023-end-state-hop-trace.md | 457 ++++++++++++++++----------- 1 file changed, 268 insertions(+), 189 deletions(-) diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index fc2905a0ce..ae0a75fb89 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -1,68 +1,88 @@ # 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 files, 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. + Only the proven removable `commands/runtime-types.ts` hop is endorsed for combination. + +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. +That paragraph cited 38 hops for `press`/Android and 29 for `snapshot`/iOS without an ordered route, definition, or linked artifact. +This file supplies all three. ## 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 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 subprocess, socket, or fetch boundary. + 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 or callee absorb the handoff without loss of admission, budget, isolation, a declared ADR boundary, or testability? + The test evaluates the hop, not the file. + REMOVABLE identifies a hop that can combine with another, not a file for deletion. +- **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 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 | @@ -74,10 +94,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 | @@ -99,65 +119,97 @@ 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`. +ADR Decision section 1 permits only the named composition module to bind this named root file. + +**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: + +- The target is ineligible. +- 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 clears it after capture. +It 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 | | 10 | `packages/daemon/src/sessions/handlers/snapshot.ts` | `handleSnapshotCommands` routes the plain arm | policy | KEEP — snapshot command routing | | 11 | `packages/daemon/src/sessions/snapshot-runtime.ts` | `dispatchSnapshotViaRuntime` → `params.execute` | pass-through | KEEP — snapshot execute seam (route tests pin it) | -| 12 | `packages/daemon/src/sessions/snapshot-command-runtime.ts` | resolves the bound capture; wires session/backend; runs the op | orchestration | KEEP — capture wiring + recording | +| 12 | `packages/daemon/src/sessions/snapshot-command-runtime.ts` | resolves the bound capture; connects session/backend; runs the op | orchestration | KEEP — capture composition + recording | | 13 | `packages/daemon/src/core/deferred-interaction-outcome.ts` | `resolveDeferredInteractionOutcome` before a fresh capture | policy | KEEP — pending-outcome settlement (may short-circuit) | | 14 | `packages/daemon/src/core/snapshot-capture.ts` | `resolveSnapshotScope` + the capture attempt | orchestration | KEEP — capture attempt + scope resolution | | 15 | `packages/daemon/src/core/snapshot-session.ts` | `resolveSessionDevice` for the capture | translation | KEEP — snapshot session/device resolution | @@ -165,15 +217,15 @@ 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` connects the AX and fallback closures | orchestration | KEEP — AX/runner composition (tests pin it) | | 25 | `packages/platform-apple/src/snapshot-route.ts` | `createAppleSnapshotRoute`; `isEligible` + circuit breaker | policy | KEEP — AX/runner route + breaker (the fork) | | 26 | `src/runtime.ts` | `createAgentDevice`: the bound runtime object | orchestration | KEEP — agent device facade | | 27 | `src/commands/index.ts` | `bindCommands` binds the command catalog | orchestration | KEEP — command catalog boundary | -| 28 | `src/commands/runtime-types.ts` | `bindRuntimeCommands` wraps the runtime into closures | pass-through | REMOVABLE — thin closure wrapper; depth lives in the commands | +| 28 | `src/commands/runtime-types.ts` | `bindRuntimeCommands` wraps the runtime into closures | pass-through | REMOVABLE — thin closure wrapper; commands own the behavior | | 29 | `src/commands/capture/runtime/snapshot.ts` | `snapshotCommand`: parses input, calls `runtime.backend.captureSnapshot` | orchestration | KEEP — capture command implementation | | 30 | `packages/contracts/src/snapshot-runtime.ts` | the `captureSnapshot` op → AX route or interactor fallback | pass-through | KEEP — cross-layer contract seam (ownership refusal + signal join) | @@ -196,7 +248,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 @@ -223,100 +275,127 @@ 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. +They are side-calls of admission under `packages/contracts/src/*-runtime.ts`. + +**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 a difference from that measurement. -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 files on the 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` now supplies a pass-through executor boundary. +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. +The traced HEAD applies the earlier deletion logic: combine every pass-through hop without retained depth. + +- **`press`/Android: no longer plausible.** + The fixed request spine, rows 1–9, already contains nine files. + Each preserves diagnostics attribution, inventory/resolution context, execution scope, provider scope, or a declared boundary. + All other pass-through hops preserve declared boundaries, interactor resolution, adb execution, or typed failure classification. + A ≤14 target requires 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 AX-bridge hops for binary cache, session lock, wire codec, and deadlines. + The fallback runner protocol requires retry, contract, recycle ledger, and readiness layers. + Only shared row 28, `commands/runtime-types`, is REMOVABLE. + Combining 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. +A further target requires a decision to combine cross-cutting request-scope wrappers outside this route's ownership. +Treat 14 as a historical discussion reference, not a derived number. From 293ed3712bf33109da4ad2577b2e2415f0a9e5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:00:02 +0200 Subject: [PATCH 08/52] docs(adr): delete superseded ADR 0025 Device Hub process --- docs/adr/0025-foldable-apple-panels.md | 51 ++------------------------ 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index b389009129..91fb306040 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -181,16 +181,7 @@ fold request policy. 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 superseded Device Hub process is retained in git history. 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 @@ -205,28 +196,7 @@ unsettled `half-open` angle is refused as `fold-pose-unsettled` (see the amendme 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. +A single-panel simulator is refused before a pose mutation (`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) @@ -285,10 +255,7 @@ claims: - 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 +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. @@ -532,22 +499,12 @@ incorrect viewport, not an inner-panel delivery prohibition. - **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 + the inventory is carried but never exercised against a changed value. - **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. - ## Android emulators (amendment, 2026-10-10) The same `fold` command poses a foldable Android emulator, with the two facts of this ADR kept From 4a1fdfe4501f5a29af2a223a0a22b022bc644dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:05:25 +0200 Subject: [PATCH 09/52] docs(adr): write ADR 0025 in ASD-STE100 --- docs/adr/0025-foldable-apple-panels.md | 940 ++++++++++++++----------- 1 file changed, 517 insertions(+), 423 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 91fb306040..fb0717b5c9 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -2,140 +2,171 @@ ## 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 from panel information. ## 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 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, report only `closed`, `fully-open`, or `unknown`. `fold` reads the hinge angle for exact pose. | +| Pose request | `agent-device fold ` sends private simulator HID. Read CoreDevice hinge angles until agreement. For `half-open`, require two consecutive readings that agree. Otherwise, refuse the pose. | +| A moving hinge reaches `half-open` | Refuse with `fold-pose-unsettled` and the observed and previous angles. An angle category does not prove a held pose. | +| 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. + Decisions use `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. + 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, which downstream consumers trust. | 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. +Only the lit panel is capturable. +The fix names that panel; it requires no screen-selection flag. -`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. +Normalization preserves its pre-panel result instead of borrowing another panel's scale. +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. +Only failure to resolve either window causes a typed, fail-closed capture refusal. +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,152 +174,192 @@ 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 private-API rejection below. +Git history retains 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. +It 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. +Its 67.9° angle remained unchanged 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 -The superseded Device Hub process is retained in git history. +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. + +`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. -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. +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 a pose mutation (`single-panel-device`). The leaf fact refuses physical devices and every non-iPhone simulator OS. +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 reported `half-open` after four reads inside that category, even without agreement. +It assumed a refusal could not 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 did not reach the requested final 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` and never proves 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 | +| `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 rather than negative, and are recorded as limits rather than refuted -claims: +Two incomplete searches limit the evidence: -- 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. +- 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 `com.apple.springboard.fold`. + 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. -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. +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 | | --- | --- | --- | @@ -296,40 +367,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 uses 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? | | --- | --- | --- | --- | @@ -337,188 +411,208 @@ 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. +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 depends on luma alone. +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 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. - **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. +- **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 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. + All observed runs settled within budget. + No slower simulator was observed, so the budget remains unchanged rather than increasing for an unobserved case. ## 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 sets foldable Android emulator poses and preserves the distinction between pose control and verification. +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 without 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. From 4a3c5f6844d44c384b843828826beaeea8e39332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:06:27 +0200 Subject: [PATCH 10/52] docs(adr): write ADR 0026 in ASD-STE100 --- docs/adr/0026-scroll-clip-authority.md | 292 ++++++++++++++----------- 1 file changed, 170 insertions(+), 122 deletions(-) diff --git a/docs/adr/0026-scroll-clip-authority.md b/docs/adr/0026-scroll-clip-authority.md index e8c2916534..b8b90b4eca 100644 --- a/docs/adr/0026-scroll-clip-authority.md +++ b/docs/adr/0026-scroll-clip-authority.md @@ -2,139 +2,187 @@ ## 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. | +| Nearest scroll ancestor requires traversal across a frame change | Own nothing. Apply no band or 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. | +| Semantic removal: collapsed row, duplicate label, 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 presentation at both input stages because every iOS scroll host 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 traversal preserves structural ownership.** +The producer states that two nodes have the same rectangle. +This is a structural fact, not a label or type inference. +A `WKWebView` page indicator resides 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 traversal 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`. +Eligibility can remove a `ScrollArea` host and re-parent its children beyond it, leaving no parent-edge 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: + +- 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`. +Only the scroll-band site accepts a weak signal. +If no indicator parent owns a band, that signal may produce directional hints. + +Each source index must end presented with its representatives or removed with a typed reason. +This would make the ledger complete by construction instead of a gate. +#2754 step 5 still owes the removal reason. +Currently, `presentedIndexesBySourceIndex` in `ios-snapshot-engine/semantic-index.ts` records representatives without removal reasons. +Extend that ledger. A parallel ledger would create another 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 adds validation, capture-local indices, and remap risks without additional information. +- **Producer-computed visible bands.** + This returns interpretation across the process boundary established by ADR 0004. + Bands remain host-side. +- **An owner from unrestricted ancestor traversal.** + That traversal caused the incident. + Exclusion of only known scroll types, as in #2740, leaves the next `WebView`, map view, or paged cell vulnerable. +- **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 restructuring a site, characterize its behavior. + 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 either rejects legitimate compaction or permits 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. +- **Same-frame traversal resolves the observed under-clipping.** + 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. + Only the web surface had this shape. + Same-frame traversal reproduces `main` clipping on all three captures. + Smaller-host cases from #2214 still own nothing. + A non-scroll wrapper with a different frame could still under-clip. + No captured iOS tree has that shape. +- **Real data confirms the `WebView` shape.** + Safari `snapshot -i --raw` publishes 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` proves that such a label 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. From 62a37f38eb5f3d1386d0b4a8f2919ae9a2b85099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:07:22 +0200 Subject: [PATCH 11/52] docs(adr): write ADR 0027 in ASD-STE100 --- ...descriptor-root-vs-eager-closure-budget.md | 289 ++++++++++-------- 1 file changed, 154 insertions(+), 135 deletions(-) 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..2f54b913b4 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,65 @@ ## 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: exact façades and no platform implementation before a binding. +The tolerance relaxes the module-count proxy, never the load property. + +## Rules at a glance, resolved + +- A statement-neutral hub split is permitted only with 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 computes a rule from two graphs, without per-split manual entries or waivers. +- Do not make registry construction asynchronous to bypass the walker. + ADR 0008 requires the synchronous root for compile-time totality. +- Flat statement weight requires preservation of the base closure. + The tolerance does not authorize 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 probe, `scripts/__tests__/eager-closure-budgets.ts`, measures each published entry surface's eager **module count** against the committed merge-base. +It prohibits increases 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 +77,120 @@ 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 **only for 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 contain no detected transplant from a source deleted since the merge-base. + Two or more identical normalized top-level statements shared with one deleted source constitute 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, deleted weight elsewhere could 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. + Composition lines are excluded so relocation itself does not affect weight. + +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 has these exact limits: + +- Newly evaluated 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 more expensive replacement for one existing top-level statement changes neither count nor weight and therefore passes. +Neither module counts nor statement counts detect like-for-like replacements. +The remaining risk is bounded by new-path novelty and the transplant probe. +Any added module with two 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 and can 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: + +- Hard ADR-0019 façade and pre-binding implementation checks 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 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 remains addressable debt rather than 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 an invariant deliberately lacking an 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 alone does not constrain 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. +- **Leave the array in place.** + This retains the conflict and file-size debt without an owner or documented resolution. +- **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 declaration vocabulary for 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. From 2a2b2b6e63c51f8fa23a456aa4f78bc90209308d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:08:04 +0200 Subject: [PATCH 12/52] docs(adr): write ADR 0029 in ASD-STE100 --- docs/adr/0029-daemon-policy.md | 201 ++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 94 deletions(-) diff --git a/docs/adr/0029-daemon-policy.md b/docs/adr/0029-daemon-policy.md index 4251a5eacb..38982e2670 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. Three successive levels enforce policy: + - **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. + One 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 with a named policy refuses a daemon with a different policy or no policy. + A client without a named policy reuses whichever policy the daemon enforces. ## Policy shape @@ -41,98 +40,112 @@ 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. + Leases, `human_control`, and session bookkeeping serve the protocol. + - 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 restrict an agent to one simulator and prevent its shutdown. +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 do not cross HTTP again. + `shutdown` supports batch execution. +- The daemon resolves names such as `--device "iPhone 16"`. + The HTTP boundary 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 level protects what the earlier level cannot decide. +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 invalidate prior admission decisions. +One load and refusal of invalid files ensure that each 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 restrictions above this per-daemon policy. +- **A programmatic policy module: deferred.** + If added, it must decide at the same three levels, not the HTTP boundary. From debcf4d5fa0d8dbab34795ecef654007d20cee16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:08:28 +0200 Subject: [PATCH 13/52] docs(adr): write ADR 0030 in ASD-STE100 --- docs/adr/0030-process-lock-exclusion.md | 133 ++++++++++++++---------- 1 file changed, 78 insertions(+), 55 deletions(-) diff --git a/docs/adr/0030-process-lock-exclusion.md b/docs/adr/0030-process-lock-exclusion.md index 55b7f403e9..56dda00377 100644 --- a/docs/adr/0030-process-lock-exclusion.md +++ b/docs/adr/0030-process-lock-exclusion.md @@ -2,74 +2,97 @@ ## 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 remains held, 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. + During hardened access, 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 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 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 taken that reclaimer's 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 retains `daemon.lock` and refuses legacy files instead of automatic reclaim. +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 daemon cutover, not expanded host-kit mixed-protocol support. +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 directory's legacy `daemon.lock` file and retained `daemon.reclaim.lock` guard, if present. +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. +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 From 03c2c28ad8035a0574d126b0015563b413a4dfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:09:03 +0200 Subject: [PATCH 14/52] docs(adr): write ADR 0031 in ASD-STE100 --- docs/adr/0031-macos-native-app-backend.md | 183 ++++++++++++---------- 1 file changed, 102 insertions(+), 81 deletions(-) diff --git a/docs/adr/0031-macos-native-app-backend.md b/docs/adr/0031-macos-native-app-backend.md index 228bb88980..b47ae86714 100644 --- a/docs/adr/0031-macos-native-app-backend.md +++ b/docs/adr/0031-macos-native-app-backend.md @@ -2,96 +2,117 @@ ## 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`, `app` uses the helper, like `frontmost-app`, `desktop`, and `menubar`. +3. A native daemon never starts XCTest on macOS. + For every macOS session, `macOsNativeBackendFacts` refuses runner-only operations 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` explicitly assembles each member and includes no runner-backed member. + 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 reaches 48 levels 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 enables host Automation Mode. +This shows a system overlay and 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 failed in foreground and background. +Wheel events failed 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 could 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. + Default changes require coverage evidence across app frameworks. From 4e4cf72dc0eb717919a95eb4a4d1f6394be78b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:09:57 +0200 Subject: [PATCH 15/52] docs(adr): write ADR 0032 in ASD-STE100 --- docs/adr/0032-layering-graph-engine.md | 207 +++++++++++++++---------- 1 file changed, 122 insertions(+), 85 deletions(-) diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 170c1fe457..5b440990ec 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: nine file pairs with TypeScript `import('x').T` type positions. + `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 only 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 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 `.fallowrc.json` field count also refer to 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/type-only edges. +Version 18.5 detects all 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` and adds an 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 R4/R5/R6 pair helpers remain because `scripts/depgraph/` consumes them. | 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. +The evaluated replacements lack sufficient semantic parity and net simplification. +Graph-based checks share one normalized, replaceable import model. +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 lazy boundaries permitted by R5 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 supplies the shared model to 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` excludes `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. From c25a463249be3085d5c97edb695d93e0bd5eb8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:10:07 +0200 Subject: [PATCH 16/52] docs(adr): delete completed ADR 0033 move batches --- docs/adr/0033-collocation-decision-table.md | 26 +++------------------ 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 5a1dd88ec6..a34f5527ad 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -3,7 +3,7 @@ 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. +Moves land as `refactor(move)` PRs. 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` @@ -26,9 +26,6 @@ longer bind; re-evaluate those files on their remaining edges. "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. - ## Decision table ### capture-kit @@ -167,28 +164,11 @@ optimal. ## 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). +The completed batch is retained in git history. ## 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. +The completed move records are retained in git history. The decision table records the ownership rationale. ## The daemon package's export surface From ac3f84b2306de5c7501abdadcded1b7d1bf802ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:11:29 +0200 Subject: [PATCH 17/52] docs(adr): write ADR 0033 in ASD-STE100 --- docs/adr/0033-collocation-decision-table.md | 269 +++++++++++--------- 1 file changed, 143 insertions(+), 126 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index a34f5527ad..1546172f17 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -1,211 +1,228 @@ # 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. - -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 records the rationale for #3287's move, later deleted with that package. +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. +- A `keep` row records a decision. + 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. + Shared foundations often belong to their consumer's detected community. + The table records valid ownership locations, often the existing locations. +- A move requires an actual target boundary. + It introduces no new dependency direction, moves no root composition into a package, and preserves the file's imports. + If a proposal is infeasible, record that result without an issue to propose it again. +- Path-keyed R13 importer lists, R76 inventory, and R78 scope describe existing boundaries. + They do not prove optimal placement. + If a row has only path evidence, mark it "retain for this batch — relocation requires coordinated ownership changes". + A package cycle rejects that proposed direction. + It does not prohibit a future design that first removes the cycle-closing edge. + ## Decision table ### capture-kit | 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 adds capture-kit→managed-allocation. 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 four-line specialization belongs beside its package-owned ledger. | +| `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. Contracts beside `json.ts` was also rejected: R18 owns 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 | This is platform-neutral freshness policy. Capture-kit, platforms, and daemon currently import it downward. | +| `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 published contracts facade defines the package boundary. It is not movable implementation 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/` associate it with this package. | +| `src/managed-device-allocation.ts` | move (batch 1) → `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 boundary 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 | AGENTS.md mandates this diagnostics boundary for all zones. 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 host-kit as the boundary for these cross-layer request contracts. 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 | Only kernel places this device-isolation vocabulary below device-selection, replay-port, and daemon. | ### 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. Root consumers perform composition through their own platform package loads. | ### 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 | ADR 0005 defines the runner-owner boundary. R13 owns platform 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 package's kernel device model and xctestrun preparation must remain synchronized with them. | ### 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 daemon-port adapter already colocates its shard enumeration binding. The `replay-test` scheduler deliberately never enumerates hardware. A move toward that zone breaks 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`. +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** to remove client imports of `packages/daemon/src/`. +The `client-side` logical-module policy prohibits those imports 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 say "retain for this batch". +Current path lists constrain moves without independently proving optimal placement. | 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 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 with both consumers and gateway-only root composition, per ADR 0019 section 1. The package depends on `capture-kit`. A reverse 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 | R13's exact-importer rule and ADR 0022's R76 inventory use this `platform-runtime-*` composition path. Relocation requires coordinated 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 boundary requires coordinated declaration changes for relocation. | +| `src/platform-runtime-device-boot.ts` | retain for this batch | The same path-keyed R13/ADR 0022 boundary applies. | +| `src/platform-runtime-resource-cleanup.ts` | retain for this batch | The same path-keyed R13/ADR 0022 boundary applies. | +| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Like `daemon-policy-file.ts`, this shared daemon/client contract follows #2559. #3288 places it beside provider composition that consumes fingerprinted credentials. Daemon extraction PR 6 moved only `DaemonProviderCredentials` to `packages/daemon-contracts` at `./provider-credentials`, below both consumers. The implementation remains here because it reads the installed-plugin manifest. | +| `src/provider-limrun-runtime.ts` | keep | The proposed provider-limrun destination lacks a valid boundary. Its constructor builds root `src/sdk/limrun-runtime-dependencies.ts`, the ADR 0019 composition factory. 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 composition and therefore requires design changes. | +| `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 publishes `agent-device/limrun`. `tsdown.config.ts` builds 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 replaces provider session types with platform `AndroidAdbProvider` types. The published surface differs from the internal surface, whose identical names it `Omit`s. A provider-limrun home requires renamed or dual-exported internal types. That requires design changes, not only a move. | ## Batch 1 (this decision's companion PR) -The completed batch is retained in git history. +Git history retains the completed batch. ## Follow-ups (tracked on #3281) -The completed move records are retained in git history. The decision table records the ownership rationale. +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 named values/types used by process-root composition and platform-runtime modules. +These include `startDaemonRuntime` and required types. +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. + The decision 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. +Current R13, R76, and R78 path lists constrain relocation but never independently prove required placement. +If only a path supports retention, mark the row "retain for this batch". +A later pass then re-examines it instead of treating it as settled. From d9e6a67b76d99566f0c9ff91c5ecf99ef37aec88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:17:47 +0200 Subject: [PATCH 18/52] docs(adr): delete completed ADR 0019 substrate adoption instructions --- docs/adr/0019-request-bound-platform-runtime.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 511f8b7424..1458b96da2 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -679,20 +679,6 @@ Descriptors cannot carry capability admission. R66 rejects manufactured runtime proof and restored legacy admission. R65 rejects daemon dependencies on concrete platform implementations. -A behavior-neutral package/registry substrate may precede the first command only under all these conditions: - -- It routes no production descriptor. -- It contains no dead code. -- It is independently revertible. -- It preserves lazy loads. - -Otherwise, it accompanies the `devices` command unit. -Migrated and legacy commands do not duplicate shared Apple-runner or Android-helper mechanics. -They do not use package-to-root back-imports. -Mechanics either remain in place until the last consumer can move or use a neutral lower-level capability injected by composition. -Composition injects that capability into both the package facet and complete legacy adapter. -Command execution retains one owner. - Review the substrate and each command unit from a clean committed tree with all production files in `HEAD`. A passing layering check with untracked production files is not evidence. Platform packages receive no migration allowlist or root back-import exception. From d20256e02df08fc8da19e8b74c7b673ae88b5c6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:38:34 +0200 Subject: [PATCH 19/52] docs(adr): restore the ADR 0019 move-unit size rule The deletion of the completed checkpoint history (667484cbde) also removed the operative size rule of section 8. The language commit re-added only one sentence of it. Restore the full rule: the original-baseline and stack-base bounds, the zero-or-negative expected delta, the root-versus-package byte report, the named justification of each addition, type inflation as size growth, and the #1739 evidence home. --- docs/adr/0019-request-bound-platform-runtime.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 1458b96da2..9462200d98 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -702,8 +702,20 @@ Git history retains the completed checkpoint procedures and results. ### 8. Broader migration was move-dominated and evidence-tiered -The completed checkpoint budgets provide no allowance for future units. -Each unit must justify exceptional growth individually. +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 the checkpoint. +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. +No unit treats the checkpoint budget as an allowance. + Root re-export shims, a broad barrel, or ambient filesystem/process access would reduce source movement. They would violate package closure, the prohibition on compatibility shims, named facets, host injection, and implementation laziness. From 5954079ef0e73e2b34179f5e8d1108befb74084a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:42:21 +0200 Subject: [PATCH 20/52] docs(adr): restore the frozen meaning in ADR 0021 Restore "replaced" in Status and the "ADR text before 2026-10-10" recovery pointer. Restore the leased-device scope of remote access, the "is the device leased" check, the Simlock owner of the HTTP API, and the "address a device" requirement. Keep the base sentence about the adb server, because its "which" referent is ambiguous. --- ...021-host-simlock-managed-device-allocation.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/adr/0021-host-simlock-managed-device-allocation.md b/docs/adr/0021-host-simlock-managed-device-allocation.md index 99348271bb..ac56086644 100644 --- a/docs/adr/0021-host-simlock-managed-device-allocation.md +++ b/docs/adr/0021-host-simlock-managed-device-allocation.md @@ -2,13 +2,13 @@ ## Status -Accepted (2026-09-01). The decision of 2026-10-10 withdraws the in-process Host allocator integration. +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 through `simlock lease`, the Simlock MCP tool, or its HTTP API. +- 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 uses the ordinary local runtime, sessions, and process-owned device claims for a leased device. @@ -22,9 +22,7 @@ agent-device follows that design. ## 1. Context Simlock prevents parallel agents from competing for one simulator or emulator. -Its simulators reside in a device set that Xcode does not read. -Its emulators register with Simlock's own adb server. -These boundaries prevent another tool from erasing a leased device. +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`. @@ -37,7 +35,7 @@ The first design proposed a Host inside agent-device with these responsibilities agent-device shipped these foundations but never activated them. Simlock then selected direct device leases by agents. -This requires only access to devices inside Simlock's roots. +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 @@ -54,7 +52,7 @@ agent-device open MyApp --platform ios --ios-simulator-device-set "$SIMLOCK_IOS_ The lease provides exclusion between agents. Process-owned device claims still prevent two sessions of one installation from driving the same device. Simlock's "advisory, not a sandbox" model applies. -agent-device does not verify a device lease. +agent-device does not verify that a device is leased. ## 3. Consequences @@ -69,7 +67,7 @@ agent-device does not verify a device lease. 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 uses Simlock's gateway or HTTP API, or the existing agent-device `proxy` and remote daemon. +- 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. @@ -87,4 +85,4 @@ The replaced design required these components: - 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 before 2026-10-10 and #2284, #2308, and #2312. +If an in-process allocator returns, recover that design from git history: the ADR text before 2026-10-10, #2284, #2308, and #2312. From 04229ef4e9a329d59e267761175c404d40368826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:42:21 +0200 Subject: [PATCH 21/52] docs(adr): restore the frozen meaning in ADR 0022 Rewrite the R75 glance rule as the membership ratchet that the gate enforces. Name the whole root platform-runtime family, which includes src/platform-runtime.ts. Restore the querySelector CONDITIONAL attribution, the separate landed child issues, "leaked", the generic hook bag prohibition, the kill criterion, the request-spine guards, the owning seam, and "fold"/"collapse" as two separate words. --- .../0022-daemon-platform-runtime-coupling.md | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 34d9b74f55..1e6d962434 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -16,17 +16,20 @@ Daemon-runtime composition edges moved from `packages/daemon/src/` to process-ro 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. -As its retirement criterion required, R76 became a coupling ban and removed classification machinery. +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 production daemon imports of root `src/platform-runtime-*.ts` modules or root hubs that reach them. +- 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`. -- Platform owners participate through existing lifecycle phases. There is no generic hook collection. +- 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 permits handler-owned state-shape and store-authority file sets to shrink, but never grow beyond the merge-base. -- Route reduction is limited to hops that the deletion test proves removable. +- 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 hops that the deletion test proves removable. The ≤14 target is superseded. ## 1. Context @@ -46,7 +49,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted ## 2. Decision 1. **R76 prohibits daemon coupling to root platform-runtime modules.** - Any production `packages/daemon/src/**` import of `src/platform-runtime-*.ts` or a root hub that reaches this family violates R76. + 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. @@ -58,7 +61,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted - `device-claim-owner-recovery.ts` → `platform-runtime.ts`: owner recovery. - `device-ready.ts` → `platform-runtime-device-ready.ts`: device readiness. - The remaining nine edges exposed platform mechanics. Their owners or follow-ups are below: + 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. @@ -71,23 +74,23 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted `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 retained `resource-cleanup` edge became composition-essential and carried only `platformResourceCleanup`. + 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 their owner in `packages/platform-android`. + 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. - Live reads use `querySelector` on the request-bound owner. - `SelectorObservationRuntimeOperations` declares it CONDITIONAL on captured touch and gesture uses. + 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. - It adds no second selector producer. + 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. @@ -95,7 +98,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted 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. - Classification permits this ratchet. + 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. @@ -105,44 +108,45 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted [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`. - Auth comparison, cancellation, idle-reap reset, and Android freshness guard/clear are side-calls, not hops. + 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 requires combination of cross-cutting request-scope wrappers, which this audit does not endorse. + 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 uses the existing lifecycle phases, without generic hooks (#2333). + - 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 new trace and permits combination only of proven pass-through hops. - This change combines none. + - Route depth records the re-traced routes and collapses only the proven pass-through hops. + This change collapses none. ## 3. Rationale - **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 ownership boundary. + 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 admission, sessions, conflicts, locks, cancellation, teardown order, artifact publication, or response/event semantics. + 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 permitted independent review and preserved each consumer's behavior. + 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 that boundary; 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 regression from #2278: additional handler access to state shape and store authority. + 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.** +- **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. Every retained hop now has a documented role. - Each pass-through/translation hop also has a retained-depth or removable verdict. - The one proven removable hop is the only endorsed combination. + 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 @@ -153,4 +157,5 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted 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. -- Completed child issues #2333, #2334, and #2273/#2274 with the `querySelector` runtime operation; see §2.2. +- Child issues #2333 and #2334 landed; see §2.2. + #2273/#2274 landed with the `querySelector` runtime operation; see §2.2. From 3b47cb851202c41c1f9032d36b69db6934c1f5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:42:32 +0200 Subject: [PATCH 22/52] docs(adr): restore the frozen meaning in ADR 0023 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the comparability sentence for the counting definition, the collapse and fold vocabulary of the ≤14 section, the decision (not the wrappers) as the thing outside this route's ownership, the seam and kept-depth terms, the wiring rows, "multiple" calls, the capture as the ineligible subject, and adapter examples as examples. Revert the generalized 38/29 sentence to the base text; its stale "higher" claim is a discrepancy for the PR body, not a language fix. --- docs/adr/0023-end-state-hop-trace.md | 54 +++++++++++++++------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index ae0a75fb89..e98ca1d020 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -7,18 +7,22 @@ Reference trace for the entry-to-platform hop counts in ADR 0019 and the couplin ## Rules at a glance - Count each distinct production `.ts` file once on the main path, including both endpoints. -- Exclude type-only files, side-calls, response-only files, and untraced forks from hop counts. +- 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. - Only the proven removable `commands/runtime-types.ts` hop is endorsed for combination. + 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 for `press`/Android and 29 for `snapshot`/iOS without an ordered route, 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. @@ -46,21 +50,21 @@ This file supplies all three. 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 calls, binds/runs a runtime, or assembles the object graph needed by later hops. + - **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 subprocess, socket, or fetch boundary. + - **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 or callee absorb the handoff without loss of admission, budget, isolation, a declared ADR boundary, or testability? + 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. - REMOVABLE identifies a hop that can combine with another, not a file for deletion. + 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 function that hands control to the next hop. + 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. @@ -179,7 +183,7 @@ The route branches at `packages/platform-apple/src/snapshot-route.ts` through `r The in-simulator AX bridge is primary. The XCTest runner is fallback under any of these conditions: -- The target is ineligible. +- The capture is ineligible. - AX acquisition fails. - Single-flight target discovery has not settled within this capture's `TARGET_DISCOVERY_WAIT_MS` wait in `snapshot-target.ts`. @@ -191,8 +195,8 @@ 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 clears it after capture. -It has no effect on iOS. +`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) @@ -209,7 +213,7 @@ It has no effect on iOS. | 9 | `packages/daemon/src/sessions/request-handler-chain.ts` | `runRequestHandlerChain` routes `snapshot` to `runSnapshotHandler` | orchestration | KEEP — command routing | | 10 | `packages/daemon/src/sessions/handlers/snapshot.ts` | `handleSnapshotCommands` routes the plain arm | policy | KEEP — snapshot command routing | | 11 | `packages/daemon/src/sessions/snapshot-runtime.ts` | `dispatchSnapshotViaRuntime` → `params.execute` | pass-through | KEEP — snapshot execute seam (route tests pin it) | -| 12 | `packages/daemon/src/sessions/snapshot-command-runtime.ts` | resolves the bound capture; connects session/backend; runs the op | orchestration | KEEP — capture composition + recording | +| 12 | `packages/daemon/src/sessions/snapshot-command-runtime.ts` | resolves the bound capture; wires session/backend; runs the op | orchestration | KEEP — capture wiring + recording | | 13 | `packages/daemon/src/core/deferred-interaction-outcome.ts` | `resolveDeferredInteractionOutcome` before a fresh capture | policy | KEEP — pending-outcome settlement (may short-circuit) | | 14 | `packages/daemon/src/core/snapshot-capture.ts` | `resolveSnapshotScope` + the capture attempt | orchestration | KEEP — capture attempt + scope resolution | | 15 | `packages/daemon/src/core/snapshot-session.ts` | `resolveSessionDevice` for the capture | translation | KEEP — snapshot session/device resolution | @@ -221,11 +225,11 @@ It has no effect on iOS. | 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` connects the AX and fallback closures | orchestration | KEEP — AX/runner composition (tests pin it) | +| 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 | -| 28 | `src/commands/runtime-types.ts` | `bindRuntimeCommands` wraps the runtime into closures | pass-through | REMOVABLE — thin closure wrapper; commands own the behavior | +| 28 | `src/commands/runtime-types.ts` | `bindRuntimeCommands` wraps the runtime into closures | pass-through | REMOVABLE — thin closure wrapper; depth lives in the commands | | 29 | `src/commands/capture/runtime/snapshot.ts` | `snapshotCommand`: parses input, calls `runtime.backend.captureSnapshot` | orchestration | KEEP — capture command implementation | | 30 | `packages/contracts/src/snapshot-runtime.ts` | the `captureSnapshot` op → AX route or interactor fallback | pass-through | KEEP — cross-layer contract seam (ownership refusal + signal join) | @@ -280,7 +284,7 @@ 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. -They are side-calls of admission under `packages/contracts/src/*-runtime.ts`. +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: @@ -339,7 +343,8 @@ They are side-calls of admission under `packages/contracts/src/*-runtime.ts`. 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 a difference from that 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 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. @@ -370,7 +375,7 @@ Four groups explain the growth: 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` now supplies a pass-through executor boundary. + 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`. @@ -378,24 +383,25 @@ Four groups explain the growth: ## The ≤14 target -The traced HEAD applies the earlier deletion logic: combine every pass-through hop without retained depth. +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 preserves diagnostics attribution, inventory/resolution context, execution scope, provider scope, or a declared boundary. - All other pass-through hops preserve declared boundaries, interactor resolution, adb execution, or typed failure classification. - A ≤14 target requires removal of 27 files with cross-cutting daemon responsibilities or necessary bindings. + 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 AX-bridge hops for binary cache, session lock, wire codec, and deadlines. - The fallback runner protocol requires retry, contract, recycle ledger, and readiness layers. + 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. - Combining this thin closure wrapper changes 47/49 to 46/48. + 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. -A further target requires a decision to combine cross-cutting request-scope wrappers outside this route's ownership. +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. From d610c508826949bf31631d7721dc52ac3d755814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:42:38 +0200 Subject: [PATCH 23/52] docs(adr): restore the frozen meaning in ADR 0025 Restore the accepted gap that the hinge stream never ran on a physical device, which the Device Hub deletion removed. Restore the glance rows for an unsettled hinge, the refusal of any pose that never agrees, and the panel-power-only pose report. Restore "only when neither window resolves", the two facts of the ADR in the Android amendment, the 27.1 toolchain check, the former half-open rule, the unverified refusal, and the dangling private-API pointer. --- docs/adr/0025-foldable-apple-panels.md | 50 ++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index fb0717b5c9..f3b2fc6ec9 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -13,20 +13,20 @@ 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 from panel information. +Only inference answers the second question. ## Rules at a glance | Situation | Behavior | | --- | --- | -| Any iOS simulator capture | First resolve the CoreDevice display table. Capture the panel that the system lights. | +| 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, report only `closed`, `fully-open`, or `unknown`. `fold` reads the hinge angle for exact pose. | -| Pose request | `agent-device fold ` sends private simulator HID. Read CoreDevice hinge angles until agreement. For `half-open`, require two consecutive readings that agree. Otherwise, refuse the pose. | -| A moving hinge reaches `half-open` | Refuse with `fold-pose-unsettled` and the observed and previous angles. An angle category does not prove a held pose. | +| 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. | @@ -62,7 +62,7 @@ It is the only reader of CoreDevice display information. 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. - Decisions use `primary` and panel power. + 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. Emit `apple_display_primary_geometry_conflict`; do not silently replace identity with a geometry rule. @@ -102,8 +102,9 @@ It exits 0 with a valid black PNG, which downstream consumers trust. | `screenshot` on iPhone 17 | 402x874 @1x, mean luma 104.9 | unchanged | 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. -The fix names that panel; it requires no screen-selection flag. +Thus, the name of the lit panel is the whole fix, and a screen-selection flag is not warranted. `SIMULATOR_MAINSCREEN_SCALE` also refers to one fixed panel. A capture can use the other panel. @@ -135,7 +136,7 @@ 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. -Only failure to resolve either window causes a typed, fail-closed capture refusal. +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. @@ -179,12 +180,12 @@ 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 private-API rejection below. -Git history retains the obsolete UI discovery/sidebar process and its evidence gaps. +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. -It has no Device Hub option. +`open` has no Device Hub option. The helper uses IOCFSerialize for `{provider: "com.apple.Virtualization.VirtualMachines", source: "hinge-slider-control", type: "range", value: angle}`. @@ -214,7 +215,7 @@ 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. -Its 67.9° angle remained unchanged beyond the five-second deadline. +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. @@ -231,6 +232,7 @@ 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. @@ -251,8 +253,8 @@ 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 former rule reported `half-open` after four reads inside that category, even without agreement. -It assumed a refusal could not name the requested pose. +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. @@ -276,7 +278,7 @@ 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 did not reach the requested final pose. +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. @@ -310,14 +312,14 @@ The shipping-toolchain investigation recorded these candidate results: | `devicectl` | `device info displays` reports panel state only | | `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 incomplete searches limit the evidence: +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 `com.apple.springboard.fold`. + 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`. @@ -470,7 +472,7 @@ 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 depends on luma alone. +No claim here depends on luma. PNG headers supply captured dimensions in native panel geometry. Capture orientation presents some images turned. @@ -546,7 +548,7 @@ 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 in `contracts/fixtures/window-coordinate-space.json` maps window (476,226) to native digitizer (226,475). +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. @@ -572,7 +574,7 @@ The silent misses investigated here came from default outer-display routes and i 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 as the no-change control (#2741): + 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. @@ -593,10 +595,14 @@ The silent misses investigated here came from default outer-display routes and i The former rule would have reported pose. All observed runs 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 sets foldable Android emulator poses and preserves the distinction between pose control and verification. +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: @@ -611,7 +617,7 @@ 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 without these device states has no hinge. +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. From f3d21f02fdf3d9aa24d04c16a248bfd56da3cc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:43:14 +0200 Subject: [PATCH 24/52] docs(adr): restore the frozen meaning in ADR 0026 Restore that a weak signal reaches only the scroll-band site and that directional hints are its outlet only when nothing is the indicator's parent. Restore "characterize each site before you reshape any of them", the subject and "only" of the frame-change glance row, examples as examples, the refuted alternatives as hypotheticals, and the pinned scroll host as the test subject. --- docs/adr/0026-scroll-clip-authority.md | 42 ++++++++++++++------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/adr/0026-scroll-clip-authority.md b/docs/adr/0026-scroll-clip-authority.md index b8b90b4eca..b1df922095 100644 --- a/docs/adr/0026-scroll-clip-authority.md +++ b/docs/adr/0026-scroll-clip-authority.md @@ -28,10 +28,10 @@ That inference determined output membership. | Situation | Behavior | | --- | --- | | 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. | -| Nearest scroll ancestor requires traversal across a frame change | Own nothing. Apply no band or clip. The smaller `TextView`/`WebView`/cell host retains its scrollability. A frame change ends traversal, regardless of label or type. | +| 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. | -| Semantic removal: collapsed row, duplicate label, wrapper content | This is compaction, outside this ADR. It retains its own authority. | +| 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. | @@ -41,7 +41,7 @@ That inference determined output membership. 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 presentation at both input stages because every iOS scroll host is regular-eligible. +The edge survives until presentation at both input stages because every iOS scroll host is regular-eligible. Projection therefore re-parents indicators only to that host. These two lists encode the same fact and must remain synchronized: @@ -60,7 +60,7 @@ Both contain these types: **Same-frame traversal preserves structural ownership.** The producer states that two nodes have the same rectangle. This is a structural fact, not a label or type inference. -A `WKWebView` page indicator resides under `WebView` wrappers that exactly fill its `ScrollView`. +Same-frame traversal 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. @@ -72,7 +72,8 @@ Same-frame traversal must not restore #2214's failure. 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`. -Eligibility can remove a `ScrollArea` host and re-parent its children beyond it, leaving no parent-edge owner. +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: @@ -92,7 +93,7 @@ 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: +Most of the engine's 23 removal sites delegate semantics or remove decoration, for example: - A collapsed row. - A duplicate label. @@ -102,11 +103,11 @@ Most of the engine's 23 removal sites delegate semantics or remove decoration: 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`. -Only the scroll-band site accepts a weak signal. -If no indicator parent owns a band, that signal may produce directional hints. +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 would make the ledger complete by construction instead of a gate. +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. Extend that ledger. A parallel ledger would create another source of truth. @@ -115,13 +116,14 @@ Extend that ledger. A parallel ledger would create another source of truth. - **A producer-reported ownership field on `IosRunnerPresentation`.** The parent edge already supplies the fact. - A field adds validation, capture-local indices, and remap risks without additional information. + A field would add validation, capture-local indexing, and remap hazards, and give no new information. - **Producer-computed visible bands.** - This returns interpretation across the process boundary established by ADR 0004. + 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 from unrestricted ancestor traversal.** - That traversal caused the incident. - Exclusion of only known scroll types, as in #2740, leaves the next `WebView`, map view, or paged cell vulnerable. +- **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` @@ -131,7 +133,7 @@ Extend that ledger. A parallel ledger would create another source of truth. - `RunnerTests+Snapshot.swift` Their contents differ. Only `tree.ts` accepts macOS `scrollarea`, which the iOS runner never emits. - Before restructuring a site, characterize its behavior. + 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. @@ -143,7 +145,7 @@ Extend that ledger. A parallel ledger would create another source of truth. First inventory sites in #2754. Redesign only what that inventory justifies. - **A shrinkage guard instead of the differential.** - A threshold either rejects legitimate compaction or permits a 74 → 20 collapse. + 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. @@ -158,13 +160,13 @@ Extend that ledger. A parallel ledger would create another source of truth. 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. - Only the web surface had this shape. + The survey found exactly this shape on the web surface. Same-frame traversal reproduces `main` clipping on all three captures. Smaller-host cases from #2214 still own nothing. - A non-scroll wrapper with a different frame could still under-clip. + 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.** - Safari `snapshot -i --raw` publishes the `WKWebView` indicator under `Other` → `WebView` → `WebView`. + 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.** @@ -172,7 +174,7 @@ Extend that ledger. A parallel ledger would create another source of truth. 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` proves that such a label leaves the parent list's band intact. + `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. From 0b59119e0b4005e2f8ad9dbde99058cd1518d416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:43:51 +0200 Subject: [PATCH 25/52] docs(adr): restore the frozen meaning in ADR 0027 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore "exactly when it is a split" as "if and only if", the glance rule that the tolerance guarantees only flat weight over a preserved base closure, and the code-quoted terms "wiring lines", "loading-shape probe", "loading property", and "façade exactness". Restore "two or more shared" statements, "exactly these facts", the like-for-like replacement scope, and the refuted alternatives as hypotheticals. --- ...descriptor-root-vs-eager-closure-budget.md | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) 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 2f54b913b4..1299874742 100644 --- a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md +++ b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md @@ -14,21 +14,22 @@ Rename canonicalization preserves a `git mv` of a module already inside the base 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: exact façades and no platform implementation before a binding. -The tolerance relaxes the module-count proxy, never the load property. +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 statement-neutral hub split is permitted only with containment, added-module novelty, and flat module-scope statement count. +- 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 computes a rule from two graphs, without per-split manual entries or waivers. + 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. -- Flat statement weight requires preservation of the base closure. - The tolerance does not authorize additional eager edges beneath a façade. +- 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. @@ -47,8 +48,8 @@ Consumers reach these values through **value** imports. `AGENTS.md` states that files beyond 1,000 lines are architecture debt and must split before new behavior. The descriptor root exceeds that limit. -The ADR 0019 probe, `scripts/__tests__/eager-closure-budgets.ts`, measures each published entry surface's eager **module count** against the committed merge-base. -It prohibits increases and deliberately has no approval path for growth. +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 necessarily adds static value edges. @@ -90,7 +91,7 @@ The gate proxy and file-size proxy conflict at precisely this structure, so the ## Decision -Byte-neutral relocation of hub contents may increase eager module count **only for a split**. +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.** @@ -105,12 +106,12 @@ Three facts determine eligibility from the closure graphs and merge-base tree, w 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, deleted weight elsewhere could fund that new edge. + 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. - Composition lines are excluded so relocation itself does not affect weight. + 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. @@ -124,7 +125,7 @@ Together, the three facts permit extraction without increased module-scope state Statements measure the preserved declarations and calls more accurately than bytes. Formatting and comments change bytes without changing those statements. -The guarantee has these exact limits: +The guarantee is exactly these facts: - Newly evaluated paths are absent from the merge-base tree. - Each shares at most one coincidental statement with any source deleted since the merge-base. @@ -132,37 +133,38 @@ The guarantee has these exact limits: `movedFromDeletedSource` deliberately uses the two-statement move threshold. The guarantee does not establish equal eager behavior. -A more expensive replacement for one existing top-level statement changes neither count nor weight and therefore passes. -Neither module counts nor statement counts detect like-for-like replacements. +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 shared normalized statements from one deleted source counts as pre-existing code. +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 and can pass as novel. +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: -- Hard ADR-0019 façade and pre-binding implementation checks remain count- and pattern-based. +- 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 and its later retirement. +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 remains addressable debt rather than accepted state. +A statement-neutral split is shippable, so an oversized hub is addressable debt, not accepted state. ## Alternatives considered and refuted - **An approved-growth row like `APPROVED_OVER_CEILING`, with issue, reason, and owner: refuted.** - It is auditable but creates an allowlist for an invariant deliberately lacking an approval path. + 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 alone does not constrain eager work. + 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. @@ -178,7 +180,8 @@ A statement-neutral split is shippable, so an oversized hub remains addressable - **Weight alone, #2469 candidate 1: refuted.** The planted drop test replaces modules while retaining the same statement total. - **Leave the array in place.** - This retains the conflict and file-size debt without an owner or documented resolution. + 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. From 3ede2872f9b7f958296bfe2f5ae2162c9adbd5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:44:14 +0200 Subject: [PATCH 26/52] docs(adr): restore the frozen meaning in ADR 0029 Restore "the only exception" for the capability gate, the examples of protocol plumbing as examples, the remote agent and the simulator as the referents in Context, the three enforcement "points" and the backstop rule, the client reuse of a daemon, "every" earlier admission decision, and the per-daemon floor. --- docs/adr/0029-daemon-policy.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/adr/0029-daemon-policy.md b/docs/adr/0029-daemon-policy.md index 38982e2670..d1a804b62d 100644 --- a/docs/adr/0029-daemon-policy.md +++ b/docs/adr/0029-daemon-policy.md @@ -11,7 +11,7 @@ Accepted (2026-09-29). 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. Three successive levels enforce policy: +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. @@ -19,14 +19,14 @@ Accepted (2026-09-29). - **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. - One exception permits Apple readiness to roll back a Simulator boot started and then canceled by the same request. + 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 with a named policy refuses a daemon with a different policy or no policy. - A client without a named policy reuses whichever policy the daemon enforces. + 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 @@ -49,7 +49,7 @@ Accepted (2026-09-29). 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. - Leases, `human_control`, and session bookkeeping serve the protocol. + 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. @@ -70,7 +70,7 @@ A misspelled rule cannot silently remove a restriction. ## Context `agent-device proxy` and hook-authenticated HTTP daemons expose host devices to remote agents. -Operators need to restrict an agent to one simulator and prevent its shutdown. +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: @@ -103,13 +103,14 @@ The request gate produces an early, specific error before locks or device resolu 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 level protects what the earlier level cannot decide. +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 -Startup without the operator's policy or a policy replacement mid-session would invalidate prior admission decisions. -One load and refusal of invalid files ensure that each running daemon has exactly one policy. +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 @@ -146,6 +147,7 @@ Its digest identifies that policy. 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 restrictions above this per-daemon policy. + 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 levels, not the HTTP boundary. + If added, it must decide at the same three points, not the HTTP boundary. From a3f7e9478b138c978a6f90f4fd511b161ce3cd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:44:31 +0200 Subject: [PATCH 27/52] docs(adr): restore the frozen meaning in ADR 0030 Restore that daemon registration keeps the same daemon.lock path and refuses legacy files instead of reclaiming them. Make the separate state directory conditional on the failure to stop every user again. Restore "after that bound", "guard path", the scope "those paths" for the legacy ban, the guard's paths, the unexpanded support contract, and "any retained" guard. Keep the base "its directory guard": its referent is ambiguous. --- docs/adr/0030-process-lock-exclusion.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/adr/0030-process-lock-exclusion.md b/docs/adr/0030-process-lock-exclusion.md index 56dda00377..c420fdb189 100644 --- a/docs/adr/0030-process-lock-exclusion.md +++ b/docs/adr/0030-process-lock-exclusion.md @@ -12,17 +12,17 @@ Mixed access with the legacy host-kit protocol is unsupported. 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 remains held, release remains unverified. + 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. - During hardened access, prevent legacy versions from returning. + 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 paths has stopped. +- 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. @@ -33,11 +33,11 @@ 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 path. +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 taken that reclaimer's directory guard. +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. @@ -48,7 +48,8 @@ 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 retains `daemon.lock` and refuses legacy files instead of automatic reclaim. +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. @@ -62,7 +63,8 @@ They cover these cases: - Concurrent startup. The client refuses older registration before a signal or modification. -This proves daemon cutover, not expanded host-kit mixed-protocol support. +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 @@ -70,13 +72,13 @@ Older clients still require the deployment controls above. 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 directory's legacy `daemon.lock` file and retained `daemon.reclaim.lock` guard, if present. +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. If you cannot identify and stop every user, retain the files. -Use a separate state directory. +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. From 60ce9135e4beccef5d6fc4983dd00b8e39442638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:44:45 +0200 Subject: [PATCH 28/52] docs(adr): restore the frozen meaning in ADR 0031 Restore the code term "helper-routed" and "exactly", the passive member-by-member assembly, the 48-level limit as a maximum, the runner as the actor that moves the shared pointer, and "dropped" as the one word for lost events. --- docs/adr/0031-macos-native-app-backend.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/adr/0031-macos-native-app-backend.md b/docs/adr/0031-macos-native-app-backend.md index b47ae86714..5ed08bc3f7 100644 --- a/docs/adr/0031-macos-native-app-backend.md +++ b/docs/adr/0031-macos-native-app-backend.md @@ -13,7 +13,7 @@ Electron, Catalyst, and wheel delivery with window fields remain unmeasured. 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`, `app` uses the helper, like `frontmost-app`, `desktop`, and `menubar`. + 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 runner-only operations at admission: - Recording. @@ -22,7 +22,7 @@ Electron, Catalyst, and wheel delivery with window fields remain unmeasured. - Press and hold. - Gestures and their viewport. Refusal uses `UNSUPPORTED_OPERATION` with `reason: 'unsupported-device-backend'`, as the physical-iOS XCTest backend does. - `macOsNativeAppInteractor` explicitly assembles each member and includes no runner-backed member. + `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. @@ -36,14 +36,15 @@ Electron, Catalyst, and wheel delivery with window fields remain unmeasured. 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 reaches 48 levels and reports deeper trees as truncated. + 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 -XCTest drives a macOS app through XCUIApplication and enables host Automation Mode. -This shows a system overlay and moves the shared pointer. +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. @@ -51,8 +52,8 @@ It can therefore serve an app session without a test session while the app remai **Pointer delivery.** Initial `CGEventPostToPid` measurements used Calculator (SwiftUI) without window fields. -Mouse events failed in foreground and background. -Wheel events failed in background. +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). From 43f7f5cf7b92dd2d89ee3352d9f6f3742ed866ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:45:00 +0200 Subject: [PATCH 29/52] docs(adr): restore the frozen meaning in ADR 0032 Remove the added "only" from the fallow revisit rule. Make "replaceable" describe the model implementation again. Restore the code term "lazy seams", the parseImports miss as the gap, "misses none of these edges", "with either engine", and "does not include" for tsconfig. --- docs/adr/0032-layering-graph-engine.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 5b440990ec..5253f51ef7 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -20,13 +20,13 @@ No rule moves. - 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: nine file pairs with TypeScript `import('x').T` type positions. +- 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 only on a [trigger](#revisit-triggers). + Revisit on a [trigger](#revisit-triggers). ## Measured @@ -54,7 +54,7 @@ The nine extra pairs are `import('./x').T` type positions missed by the custom e Both replacement engines correctly detect them. The umbrella's 3.1.1 cross-check missed 88 dynamic/type-only edges. -Version 18.5 detects all edges but mislabels 46 pairs: +Version 18.5 misses none of these edges but mislabels 46 pairs: - 33 dynamic → type. - 8 dynamic+type → type. @@ -126,7 +126,7 @@ These are medians from five runs after warm-up on a shared host with load averag ### Lines deletable A complete migration would delete the rule and test lines below. -The resolver and R4/R5/R6 pair helpers remain because `scripts/depgraph/` consumes them. +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 | | --- | --- | --- | --- | --- | @@ -149,7 +149,8 @@ The current per-pair count ratchet permits that replacement. Retain the current engine. The evaluated replacements lack sufficient semantic parity and net simplification. -Graph-based checks share one normalized, replaceable import model. +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. @@ -159,7 +160,7 @@ The parity table identifies these correctness blockers: 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 lazy boundaries permitted by R5 and misses R77 `import()`. + 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. @@ -199,7 +200,7 @@ It imports internals from six layering modules: - `tracked-sources.ts` No gate would verify that it still compiles. -`tsconfig.json` excludes `scripts/layering/`, and fallow ignores it. +`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`. From 18b09effd3f9557b5583de70bf45f37529cb4755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:46:12 +0200 Subject: [PATCH 30/52] docs(adr): restore the frozen meaning in ADR 0033 Restore "seam" wherever the base used it; "boundary" stays only where the base used it. Restore that contracts holds wire vocabulary under R18, that exports", path-keyed rules as examples of a class, the rejected proposal as a hypothetical, and "the next pass re-opens it". Keep the Follow-ups deletion: #3281 and #3294 are closed and every listed move landed, so the suggested "moves continue under #3294" would be wrong. --- docs/adr/0033-collocation-decision-table.md | 67 +++++++++++---------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 1546172f17..3c426061e8 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -18,15 +18,16 @@ Re-evaluate those files against their remaining edges. - A `keep` row records a decision. 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. - Shared foundations often belong to their consumer's detected community. + 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 requires an actual target boundary. +- A move counts only when the target has a real seam. It introduces no new dependency direction, moves no root composition into a package, and preserves the file's imports. - If a proposal is infeasible, record that result without an issue to propose it again. -- Path-keyed R13 importer lists, R76 inventory, and R78 scope describe existing boundaries. - They do not prove optimal placement. + 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 inventory, 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 that proposed direction. + A package cycle rejects the proposed direction. It does not prohibit a future design that first removes the cycle-closing edge. ## Decision table @@ -35,11 +36,11 @@ Re-evaluate those files against their remaining edges. | File | Decision | Reason | | --- | --- | --- | -| `src/capture-admission/audio-probe-admission-ledger.ts` | keep (proposed move rejected) | The proposed move to managed-allocation adds capture-kit→managed-allocation. 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/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 four-line specialization belongs beside its package-owned ledger. | +| `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. Contracts beside `json.ts` was also rejected: R18 owns wire vocabulary, not durable-descriptor validation mechanics. Three managed-allocation `record-*` consumers explain the community coupling and can remain. | +| `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. 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 | This is platform-neutral freshness policy. Capture-kit, platforms, and daemon currently import it downward. | | `src/snapshot/touch-reference-frame.ts` | keep | Interaction and capture share these snapshot-geometry mechanics. Only capture-kit is below both. | @@ -60,9 +61,9 @@ All its consumers occupy one zone that already owns the operations represented b | `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 published contracts facade defines the package boundary. It is not movable implementation 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/` associate it with this package. | +| `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) → `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 boundary below them. | +| `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 @@ -77,36 +78,36 @@ All its consumers occupy one zone that already owns the operations represented b | File | Decision | Reason | | --- | --- | --- | -| `src/diagnostics.ts` | keep | AGENTS.md mandates this diagnostics boundary for all zones. Its repository-highest consumer count reflects a shared substrate, not incorrect placement. | +| `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 host-kit as the boundary for these cross-layer request contracts. Consumers span every zone. | +| `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 | Only kernel places this device-isolation vocabulary below device-selection, replay-port, and daemon. | +| `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 | ADR 0019/R13 assign boot mechanics to the platform. Root consumers perform composition through their own platform package loads. | +| `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 defines the runner-owner boundary. R13 owns platform composition exports. The root importer is the designated host. | +| `src/runner-owner-facade.ts` | keep | This is the ADR 0005 runner-owner seam. R13 owns platform 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 package's kernel device model and xctestrun preparation must remain synchronized with them. | +| `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 | This daemon-port adapter already colocates its shard enumeration binding. The `replay-test` scheduler deliberately never enumerates hardware. A move toward that zone breaks the port. | +| `src/daemon-port/session-test-shard-devices.ts` | keep | This daemon-port adapter already colocates its shard enumeration binding. The `replay-test` scheduler deliberately never enumerates hardware. A move toward that zone would break the port. | ### selectors @@ -129,7 +130,7 @@ 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`. +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. @@ -145,21 +146,22 @@ 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 say "retain for this batch". -Current path lists constrain moves without independently proving optimal placement. +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 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 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 with both consumers and gateway-only root composition, per ADR 0019 section 1. The package depends on `capture-kit`. A reverse dependency forms a project-reference cycle through `selectors`. | +| `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped 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`. A reverse 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 | R13's exact-importer rule and ADR 0022's R76 inventory use this `platform-runtime-*` composition path. Relocation requires coordinated 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 boundary requires coordinated declaration changes for relocation. | -| `src/platform-runtime-device-boot.ts` | retain for this batch | The same path-keyed R13/ADR 0022 boundary applies. | -| `src/platform-runtime-resource-cleanup.ts` | retain for this batch | The same path-keyed R13/ADR 0022 boundary applies. | -| `src/provider-credential-fingerprint.ts` | keep (type extracted) | Like `daemon-policy-file.ts`, this shared daemon/client contract follows #2559. #3288 places it beside provider composition that consumes fingerprinted credentials. Daemon extraction PR 6 moved only `DaemonProviderCredentials` to `packages/daemon-contracts` at `./provider-credentials`, below both consumers. The implementation remains here because it reads the installed-plugin manifest. | -| `src/provider-limrun-runtime.ts` | keep | The proposed provider-limrun destination lacks a valid boundary. Its constructor builds root `src/sdk/limrun-runtime-dependencies.ts`, the ADR 0019 composition factory. 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 composition and therefore requires design changes. | +| `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 inventory key on its path. Relocation requires coordinated 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 provider composition that consumes fingerprinted credentials. Daemon extraction PR 6 moved only `DaemonProviderCredentials` to `packages/daemon-contracts` at `./provider-credentials`, below both consumers. 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 composition and therefore requires design changes. | | `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. | @@ -168,8 +170,8 @@ Current path lists constrain moves without independently proving optimal placeme | File | Decision | Reason | | --- | --- | --- | -| `src/sdk/limrun.ts` | keep | This publishes `agent-device/limrun`. `tsdown.config.ts` builds 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 replaces provider session types with platform `AndroidAdbProvider` types. The published surface differs from the internal surface, whose identical names it `Omit`s. A provider-limrun home requires renamed or dual-exported internal types. That requires design changes, not only 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 requires design changes, not only a move. | ## Batch 1 (this decision's companion PR) @@ -223,6 +225,7 @@ Composed root tests reach the daemon through `startDaemonRuntime` from the entry Judge collocation by ownership: the location that minimizes independently maintained declarations per change. Community scores identify candidates only. -Current R13, R76, and R78 path lists constrain relocation but never independently prove required placement. +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". -A later pass then re-examines it instead of treating it as settled. +The next pass then re-opens it instead of treating it as settled. From 22868ceaeda8a1f9dbf1d664d0015f4d2351a6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 17:49:23 +0200 Subject: [PATCH 31/52] docs(adr): restore the frozen meaning in ADR 0019 Restore the shape rule in section 2 as a plain statement in force, including the timing "at the point the shape is requested"; #2808, #2806, and the web runtime tests already rely on it. The Status line keeps its "proposed" wording, so the conflict stays visible. Restore the operative half of the deleted section 6 substrate paragraph: no duplication or package-to-root back-import of shared Apple-runner or Android-helper mechanics, the injected-capability path, and one execution owner. Repoint the dangling substrate references in sections 6, 10, and the ADR 0009 relationship. Restore the Status outcome of the adoption checkpoint, the kernel helper scope, the only static value-import sites, the owning provider, owner cell, freezes, the time-bound await using ban, the trust choke point, "seam" as the code term, the conditional scope of the preferred/required rule, the loading shape, the "yet" qualifiers of the End state, and the declared host-port adapter. --- .../0019-request-bound-platform-runtime.md | 254 ++++++++++-------- 1 file changed, 135 insertions(+), 119 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 9462200d98..8f8180c9ce 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -2,16 +2,22 @@ ## Status -Completed (2026-08-27). Accepted on 2026-08-09. Git history retains the completed process history. +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 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 use this execution category. -None binds a request-scoped device runtime. Their domain services remain separate. +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 service. An amendment proposed on 2026-09-23 accompanies the web press-shape refusal. -It would state in section 3 that a modifier on an admitted operation is not a second operation and has no fact cell. -A per-shape refusal would belong to the owning leaf mechanic. -The operation-facts mapped type cannot express a per-shape fact key. +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. @@ -23,8 +29,8 @@ R66 enforces facts-only admission and runtime proof without historical route or Section 6 required deletion of the completed cutover table. The #2082 extraction puts all six families' implementations and family-owned tests behind private package exports. -The `src/platforms` path and former R3 folder boundary are retired. -R13 governs concrete platform imports and implementation laziness. +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 the old root path. ## Rules at a glance @@ -32,21 +38,21 @@ The `retired-platforms-zone` gate rejects the old root path. - 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. - Only root composition and R13-governed named consumer facades statically import production platform values. + 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 facts may load at composition time. - Mechanics and process-lived helper managers load only when discovery or the first family binding needs them. + 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. - Each canonical family has one local runtime owner. 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. -- Descriptors declare typed inventory use or platform-neutral required device operations. - They declare preferred optimizations and fact-conditional semantic operations separately. +- 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 through facets. Platform and provider implementations never name commands. - `RequestExecutionScope.bindDevice(device, use)` resolves ownership, validates facts and 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 authoritative and fail-closed. - A provider may supply platform transport or implement facets directly. + 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 bindings, and daemon-session durable resources have separate lifetimes. Helpers remain lazy, identity-bound, invalidatable, and idle-stoppable. @@ -54,28 +60,29 @@ The `retired-platforms-zone` gate rejects the old root path. - 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 this boundary. + Concrete platform process objects never cross the seam. - Each descriptor explicitly declares exactly one shape: `none`, `host`, `inventory`, or `device-runtime`. `none` declares no platform execution. - `host` uses a neutral typed domain service for diagnostics, owner cleanup, or managed tooling. + `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. -- Request-scoped device units prove facts, operations, and parity cells. +- Evidence is tiered by what a unit imports. + Request-scoped device units prove facts, operations, and parity cells. Only durable-resource units require 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. Conditional operations require parity evidence for correctness that depends on owner facts. - A cross-cutting facet accompanies its first consuming command unit. - Evidence controls startup recovery. Shutdown has two phases: detach, then stop. + 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 introduced a shallow `PlatformPlugin` around existing platform branches. +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. -This migration limited new platform conditionals. -However, app-log, recording, performance, and provider facets still return tags or predicates that the daemon maps to concrete implementations. +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 registry has six canonical families: @@ -86,8 +93,8 @@ The registry has six canonical families: - `linux` - `web` -HarmonyOS supplied the additional adapter requirement that the earlier platform-package checkpoint deferred. -A tag-to-implementation map leaves the daemon responsible for every platform variation, even with the conditional behind a plugin. +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: @@ -123,10 +130,10 @@ In production, only these sites may statically import concrete platform package - The composition module. - Its one private implementation submodule governed by R13. -- The consumer boundaries under `src/core/interactors/` 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 boundary. +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: @@ -164,7 +171,7 @@ Its workspace compliance includes `check:affected` selection, R11/R13 package en > **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 layout. +> 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 only mechanics that act on the host machine. > Each export is one narrow capability port, not a category barrel: @@ -177,10 +184,11 @@ Its workspace compliance includes `check:affected` selection, R11/R13 package en > - `request`: request-scoped cancellation and progress. > - `transport`: line frames, lazy HTTP/body mechanics, and constant-time secret comparison. > - `version`: the installed version from disk. -> Only a port can reach modules under `src/internal/`. +> 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. -> - Helpers that access no process, file, or environment belong to `@agent-device/kernel`, with its other primitives: +> - 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. @@ -240,14 +248,15 @@ R13 enumerates the root façade, the `./runner` subpath, and thirteen named doma - `./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 boundaries. -Daemon and root consumers currently reach runner mechanics directly through `./runner`. +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. -Exactly one composition root constructs the injected `AppleRunnerHost` port. -It supplies these host capabilities: +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. @@ -258,7 +267,7 @@ It supplies these host capabilities: - 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` boundary narrows with them. +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. @@ -270,7 +279,7 @@ During coexistence, the legacy plugin registry derives family identity from this 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 a domain package only after more than one implementation needs it. +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 @@ -294,7 +303,7 @@ These activities remain lazy: 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 does not replace the required load structure. +A passing startup threshold alone does not replace preservation of the loading shape. The tracking issue owns the exact probe and planted-red procedure. ### 2. Runtime use joins facts and narrows the bound runtime @@ -307,7 +316,8 @@ 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. -An owner must provide an advertised conditional operation because its semantically complete path depends on that observation. +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. @@ -316,7 +326,7 @@ 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`. -Coverage includes all six local family sources, every provider source, and parity for selectors and public-device projections. +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. @@ -335,23 +345,24 @@ 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. -**Proposed amendment: a shape of an operation is not an operation and would get no fact cell.** -Runtime facts would describe semantic operations. -A modifier such as a double, held, or repeated press would not create another operation or receive a fact key. -The facts map is a mapped type over operation names. -A phantom operation satisfies neither required nor preferred runtime-use shapes. -An absent owning leaf mechanic would refuse a requested shape with a typed reason and hint. -An absent member for a whole admitted operation would remain an ownership bug with a separate typed, fail-closed reason. -It would not represent an unsupported shape. +**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.** An operation may serve a closed vocabulary with unequal owner support, such as `setSetting` over the settings catalog. -Its available fact may name a fact per subject and an `unlisted` refusal for other subjects. -Before a binding, admission uses the requested subject and refuses unavailability as it does for an operation. +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 authority boundary. +`RequestExecutionScope.bindDevice(device, use)` is the trust choke point. It performs these steps: 1. Resolve the exact local or provider runtime owner. @@ -367,30 +378,30 @@ Declared preferred and conditional operations are optional and present only when Undeclared operations are inaccessible. The broad cached runtime remains private to `RequestExecutionScope`. -The narrowed type does not intersect a wide optional aggregate that exposes undeclared facets. -The descriptor and specialized handler share one declaration without type expansion. +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 **runtime-facet-narrowing gate** covers every runtime-migrated handler owner. -It rejects assertions and optional admission that manufacture required-operation proof. +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 cannot determine whether the semantic command executes. +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. -Absence is valid only with an unavailable owner fact and parity evidence for that owner's complete required path. +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 command cutover, an independent parity artifact fixes the legacy supported/unsupported cells and hints. +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. @@ -420,11 +431,11 @@ A narrow tool, transport, app-log, or recording provider is an injected dependen It does not become a second runtime owner or participate in the owner count. Provider ownership is authoritative. -If the owner lacks a required fact or facet, admission returns typed unsupported behavior. +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. -The new boundary excludes provider callback stacks through daemon handlers. +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. @@ -447,16 +458,15 @@ The underlying `DeviceBinding` implements `AsyncDisposable` and remains private 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 an acquisition fails, it releases partial state before rejection. +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. -Another cached projection never registers or disposes the same binding twice. +Caching another projection never registers or disposes the binding twice. Request execution calls the stack from `finally`. -Source-executed TypeScript does not use `await using`. -The minimum supported Node 22 runtime cannot parse it, type stripping cannot lower it, and Node 22 lacks `AsyncDisposableStack`. -The contract permits a later mechanical syntax migration. -A binding never transfers beyond the request. -Only a durable resource handle from a facet may transfer to session ownership. +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: @@ -477,8 +487,8 @@ It proves that request-binding disposal never terminates or restarts the healthy 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 evidence appropriate to its helper; elapsed time alone is insufficient. -Bind scenarios fail after each successive internal acquisition. +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. @@ -504,7 +514,7 @@ A cleanup-only failure surfaces normally. > 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. -> The committed export would include the recorder observation and native-path disposition. +> 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. @@ -514,7 +524,7 @@ App-log streams, screen recordings, and native profiler captures may outlive one 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 for a deterministic recovery attempt by the exact runtime owner. +- 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: @@ -536,7 +546,7 @@ 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 guard releases only after the authoritative active record and live `SessionState` handle/descriptor adoption both succeed. +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. @@ -570,7 +580,8 @@ 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 without a valid durable representation. +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. @@ -620,7 +631,7 @@ 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 no valid manifest exists, introduce a daemon-owned persisted record only for the demonstrated need. +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 require it. Current diagnostics/session events and a future journal from proposed ADR 0018 serve observability only. They never become recovery authority. @@ -636,18 +647,18 @@ Its neutral record contains these values: - Ownership/fence token. - Persisted lifecycle state: `open` or `completed`. -Starting, active, completing, and cleanup-pending are phase metadata on an open record, not additional lifecycle states. +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. Cleanup uncertainty cannot be 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, an operation atomically enforces the token. +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 atomic with descriptor publication. -If a native tool cannot close this crash interval, the platform retains an orphan marker or equivalent recovery protocol. +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 complete live implementation state. +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. @@ -671,15 +682,21 @@ 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. -The migration unit was one descriptor across its complete platform/provider denominator. -A temporary parametrized cutover table proved deletion of old adapter, admission, and dispatch projections before merge. -Completion deleted those rows. +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 and restored legacy admission. -R65 rejects daemon dependencies on concrete platform implementations. +R65 rejects every daemon dependency on a concrete platform implementation. -Review the substrate and each command unit from a clean committed tree with all production files in `HEAD`. +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 the behavior-neutral package/registry substrate 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. For a `packages/platform-*` change, `check:affected` must select platform contract, provider, and coverage scenarios. @@ -688,7 +705,7 @@ A new structural gate requires a planted violation that proves the intended fail A unit that changes `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. -An opaque implementation behind a neutral facet-specific handle contract is a permitted live value. +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. @@ -705,7 +722,7 @@ Git history retains the completed checkpoint procedures and results. 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 the checkpoint. +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. @@ -714,10 +731,10 @@ If net growth occurs, the unit names and justifies each contributing addition in 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. -No unit treats the checkpoint budget as an allowance. +A unit must not treat the checkpoint budget as an allowance. -Root re-export shims, a broad barrel, or ambient filesystem/process access would reduce source movement. -They would violate package closure, the prohibition on compatibility shims, named facets, host injection, and implementation laziness. +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. @@ -737,17 +754,17 @@ 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 requires its own independent review and parity evidence. -It cannot accompany a structural change incidentally. +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 for a post-action observation (`--settle`) when the action use has no capture (#3510). -The observation admits the `capture-only` plan for its own reads. +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 belongs in the execution use that the plan should select. +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. @@ -761,8 +778,8 @@ A conditional operation instead requires a parity artifact for both owner-cell g - Cells whose semantic path depends on the operation. - Cells whose required path remains complete without it. -A declaration justified only by speed belongs under `preferred`. -A declaration justified only by universal correctness belongs under `required`. +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 @@ -771,15 +788,15 @@ Examples include snapshot freshness, the system-chrome guard, presentation rules 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 violates the boundary. -Section 6 still prohibits a facet PR without a consuming command. +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 Android test-IME marker recovery and web browser-orphan recovery move to this pattern with their owning domains, not earlier. +Remaining platform-specific startup recovery, for example 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: @@ -801,7 +818,7 @@ R65 enforces the daemon end state through planted-red AST tests. It rejects static, dynamic, re-export, and type-only dependencies 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 boundary. +`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 @@ -817,7 +834,7 @@ One required dependency carries these requests across the daemon boundary: `Daem 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 operation it cannot perform itself. +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. @@ -850,11 +867,11 @@ Tests pass the same shared fixture instead of a platform-module mock behind the Inventory commands declare inventory use. - [ADR 0009](0009-apple-platform-consolidation.md): the Apple family and `AppleOS` leaf axis remain. Command migration supersedes the shallow `PlatformPlugin`. - Shared mechanics move only through the permitted injected substrate transition or after their final legacy consumer. + 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 boundary. + 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. @@ -875,14 +892,14 @@ The daemon retains these responsibilities: 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 staged cross-repository migration temporarily maintained two execution systems. -Command-atomic cutover, explicit deletion boundaries, and an early checkpoint kept each intermediate state shippable and safe to abandon. +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 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. - This changes physical ownership before proof of the behavior boundary. + 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. @@ -898,18 +915,18 @@ The completed repository retains only closed execution shapes and permanent stru 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 in-memory session store already supplies the process-local home. - A parallel handle/descriptor store duplicates ownership governed by the R7 transition owner. + 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 structure. + Contract tests also verify the import/evaluation shape. ## End state (proposed 2026-09-02, maintainer decision pending) -These proposed defaults are not an accepted commitment. -No tracking issue or Status line authorizes work against them. -They provide a numeric baseline for a possible next migration and remain subject to 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`. @@ -920,19 +937,19 @@ 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. -The file-by-file trace at its HEAD measured 41 hops for `press`/Android and 47/49 for `snapshot`/iOS. +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. 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 permit combination: +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 required by that direction is not unnecessary. +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 combine cross-cutting request-scope wrappers. -Those wrappers are outside the traced routes' ownership. +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:** @@ -941,8 +958,8 @@ The number 14 remains a historical discussion reference, not a proposed commitme 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 declared host-port adapters or mechanics whose migration is incomplete. - For example, only the named root file binds `adb-host`, per Decision section 1. + 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. @@ -956,8 +973,7 @@ The number 14 remains a historical discussion reference, not a proposed commitme 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` depends on root-owned `interaction-targeting` and Android UI-hierarchy fixtures. - - `snapshot-chrome-android-statusbar.test.ts` has those same dependencies. + - `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. @@ -966,7 +982,7 @@ The number 14 remains a historical discussion reference, not a proposed commitme 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 boundaries. + - `src/core`, with R13-governed consumer seams. - `src/commands`. - `src/cli`. - `src/cli-schema`. @@ -984,5 +1000,5 @@ The number 14 remains a historical discussion reference, not a proposed commitme - Render profiling. CLI output and SDK session normalization remain application adapters under `src/`. -Neither number has an owning issue, gate, or accepted budget. +Neither number has an owning issue, gate, or accepted budget yet. Both remain inputs to a maintainer decision, not a ratchet. From 519a8aa31bcde393f25a7ad374cfd0c8722d8160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 18:01:36 +0200 Subject: [PATCH 32/52] docs(adr): restore three conditions dropped by sentence splits - ADR 0025: emit apple_display_primary_geometry_conflict only when the primary panel is also the largest. - ADR 0025: keep the pre-panel normalization only when the runner reports no panel metadata. - ADR 0032: the revisit trigger is an engine that can supply the shared import model, not one that already supplies it. Found in review round 2. --- docs/adr/0025-foldable-apple-panels.md | 4 ++-- docs/adr/0032-layering-graph-engine.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index f3b2fc6ec9..0235505e00 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -65,7 +65,7 @@ It is the only reader of CoreDevice display information. 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. - Emit `apple_display_primary_geometry_conflict`; do not silently replace identity with a geometry rule. + 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. @@ -122,7 +122,7 @@ 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. -Normalization preserves its pre-panel result instead of borrowing another panel's scale. +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. diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 5253f51ef7..4a97123682 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -184,7 +184,7 @@ These additional migration costs do not independently decide the outcome: - Dependency-cruiser includes dependency type in its deduplication key. - Fallow boundaries gain a dynamic-import kind and rule packs cover unreachable files. -- An engine supplies the shared model to existing consumers with less total machinery than `parseImports` and `resolveImportEdges`. +- 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 From c88282d07384fb36ef271db74d11e990969c0ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:53 +0200 Subject: [PATCH 33/52] docs(adr): keep translation hops in the ADR 0022 route-depth rule The base decision applies the deletion test to pass-through and translation hops and endorses only the proven removable hop. The glance named only pass-through hops. Restore the selector "seam" term from the base rationale. https://github.com/callstack/agent-device/pull/3731#discussion_r4296672897 --- docs/adr/0022-daemon-platform-runtime-coupling.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 1e6d962434..45fe553c9e 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -29,7 +29,9 @@ Section 2 retains the classification outcomes. Git history retains the completed - 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 hops that the deletion test proves removable. +- Route depth may collapse only a hop that the deletion test proves removable. + The deletion test applies to each pass-through or translation hop. + At `27a97ee619`, the only proven removable hop is the pass-through hop `commands/runtime-types.ts`. The ≤14 target is superseded. ## 1. Context @@ -135,7 +137,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted 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 that boundary; another producer would duplicate snapshot-producer policy. + #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. From effb3117de4f9428b531930554176efb5db833e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:53 +0200 Subject: [PATCH 34/52] docs(adr): restore that the ADR 0033 managed-allocation move landed The base text says the row and Batch 1 record a move that landed in #3287 and that the package removal then deleted. The rewrite dropped that the move landed. https://github.com/callstack/agent-device/pull/3731#discussion_r4296302274 --- docs/adr/0033-collocation-decision-table.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 3c426061e8..ea62fdda93 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -8,7 +8,8 @@ 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 records the rationale for #3287's move, later deleted with that package. +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. From b090043dae3bd58e28da3ef4ac56db1a0b5d5546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:53 +0200 Subject: [PATCH 35/52] docs(adr): restore the ADR 0025 rule that the fold press is not evidence The Device Hub deletion also removed the base sentence that fold sets the pose and that only the read-back is evidence. Point to the HID amendment that replaced the Device Hub press. https://github.com/callstack/agent-device/pull/3731#discussion_r4296302293 --- docs/adr/0025-foldable-apple-panels.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 0235505e00..fb624bba7b 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -225,6 +225,9 @@ Hosted Duo tests remain local-only. ## Pose control: Device Hub's control, CoreDevice's verdict +`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°`. From 36a5996e02d2c855984e54203233fa2279a16b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:06:00 +0200 Subject: [PATCH 36/52] docs(adr): state the ADR 0022 route-depth rule with its base scope Name pass-through and translation hops in one rule, as the base deletion test does, and drop the commit hash that the base does not attach to the verdict. https://github.com/callstack/agent-device/pull/3731#discussion_r4296672897 --- docs/adr/0022-daemon-platform-runtime-coupling.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index 45fe553c9e..a5dc4106ad 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -29,9 +29,8 @@ Section 2 retains the classification outcomes. Git history retains the completed - 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 a hop that the deletion test proves removable. - The deletion test applies to each pass-through or translation hop. - At `27a97ee619`, the only proven removable hop is the pass-through hop `commands/runtime-types.ts`. +- 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 From 0943c2fd66158a91b05e9be9b7a45857f181e7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:07:31 +0200 Subject: [PATCH 37/52] docs(adr): cite R76's path-family ban in ADR 0033 ADR 0022 records that #3430 emptied the R76 inventory and reduced R76 to a plain ban on the src/platform-runtime-*.ts family. The ban still keys on these paths, so the retain-for-this-batch rationale holds; only the noun was stale. https://github.com/callstack/agent-device/pull/3731#discussion_r4296302203 --- docs/adr/0033-collocation-decision-table.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index ea62fdda93..b16660fdde 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -25,7 +25,7 @@ Re-evaluate those files against their remaining edges. It introduces no new dependency direction, moves no root composition into a package, and preserves the file's imports. 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 inventory, or R78's scope, describes today's seam. +- 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. @@ -157,7 +157,7 @@ They are not independent proof that the placement is optimal. | `packages/daemon-contracts/src/device-claim-recovery.ts` | keep (shared-below-both) | The owner-scoped 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`. A reverse 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 inventory key on its path. Relocation requires coordinated changes to both declarations. The rules constrain the move without independently proving placement. | +| `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 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. | From 86a2530a4f3d123a7d20e69928af3b94847ea45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:07:31 +0200 Subject: [PATCH 38/52] docs(adr): mark the ADR 0033 managed-allocation row as landed and removed The row is history: the move landed in #3287, and the package removal on 2026-10-10 deleted the file. State this in the row, as the Status note does, so the row does not read as a live move target. https://github.com/callstack/agent-device/pull/3731#discussion_r4296302274 --- docs/adr/0033-collocation-decision-table.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index b16660fdde..5e6dfad6c5 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -63,7 +63,7 @@ All its consumers occupy one zone that already owns the operations represented b | `src/device-boot.ts` | keep | Device-selection, platforms, and daemon-server consume this boot-phase vocabulary. | | `src/facades/device.ts` | keep | This published contracts facade defines the package boundary. It is not movable implementation 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) → `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/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 From 1c931e3d1f27cbd265d380c5eeae43d419e70fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:08:07 +0200 Subject: [PATCH 39/52] docs(adr): restore scope words in ADR 0033 rows and prose Restore base scope dropped in the rewrite: "design, not a move" for the limrun rows, the shared reason and "all" for snapshot-freshness, the fingerprint and policy-file links, the real-seam conditions, "every edge" and "at all" for client imports, "the platform package's", the capture-kit back-edge, R11's "from a non-test file", and the daemon entry's re-export scope. --- docs/adr/0033-collocation-decision-table.md | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 5e6dfad6c5..a067302fa5 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -22,7 +22,7 @@ Re-evaluate those files against their remaining edges. 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. - It introduces no new dependency direction, moves no root composition into a package, and preserves the file's imports. + 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. @@ -43,7 +43,7 @@ Re-evaluate those files against their remaining edges. | `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. 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 | This is platform-neutral freshness policy. Capture-kit, platforms, and daemon currently import it downward. | +| `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 @@ -61,7 +61,7 @@ All its consumers occupy one zone that already owns the operations represented b | `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 published contracts facade defines the package boundary. It is not movable implementation content. | +| `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. | @@ -100,7 +100,7 @@ All its consumers occupy one zone that already owns the operations represented b | File | Decision | Reason | | --- | --- | --- | -| `src/runner-owner-facade.ts` | keep | This is the ADR 0005 runner-owner seam. R13 owns platform composition exports. The root importer is the designated host. | +| `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. | @@ -108,7 +108,7 @@ All its consumers occupy one zone that already owns the operations represented b | File | Decision | Reason | | --- | --- | --- | -| `src/daemon-port/session-test-shard-devices.ts` | keep | This daemon-port adapter already colocates its shard enumeration binding. The `replay-test` scheduler deliberately never enumerates hardware. A move 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 @@ -135,8 +135,8 @@ The complete `daemon-contracts` zone resides in `packages/daemon-contracts`, beh 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** to remove client imports of `packages/daemon/src/`. -The `client-side` logical-module policy prohibits those imports from these consumers: +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. @@ -154,15 +154,15 @@ 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 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 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`. A reverse dependency forms a project-reference cycle through `selectors`. | +| `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 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 provider composition that consumes fingerprinted credentials. Daemon extraction PR 6 moved only `DaemonProviderCredentials` to `packages/daemon-contracts` at `./provider-credentials`, below both consumers. 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 composition and therefore requires design changes. | +| `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. | @@ -172,7 +172,7 @@ They are not independent proof that the placement is optimal. | File | Decision | Reason | | --- | --- | --- | | `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 requires design changes, not only a move. | +| `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) @@ -186,8 +186,8 @@ The decision table retains ownership rationale. ## The daemon package's export surface `@agent-device/daemon` has one entry, `.`, at `packages/daemon/src/index.ts`. -It explicitly re-exports named values/types used by process-root composition and platform-runtime modules. -These include `startDaemonRuntime` and required types. +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. From b38136fcee2fae8e9a04c825fff7f0f1c2e8a19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:08:36 +0200 Subject: [PATCH 40/52] docs(adr): restore scope words and a fixture ban in ADR 0019 Restore "provider" and "concrete" in the bindDevice glance, the conditional parity evidence that explains why, "touches" for the SessionState unit, "not pass-through layers" for the declared boundaries, and "grab bag". Restore the operative ban that temporary fixtures never authorize a production bridge, duplicate route, or package back-import; the section 7 deletion removed it. --- docs/adr/0019-request-bound-platform-runtime.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 8f8180c9ce..0ec25aaedb 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -11,7 +11,7 @@ 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 service. +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. @@ -49,7 +49,7 @@ The `retired-platforms-zone` gate rejects the old root path. A device-operation declaration declares any preferred optimizations and fact-conditional semantic operations separately. Runtime owners report device-specific facts and expose behavior through facets. Platform and provider implementations never name commands. -- `RequestExecutionScope.bindDevice(device, use)` resolves ownership, validates facts and facets, and returns a runtime narrowed to proven use. +- `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 authoritative and fail-closed. An owning provider may supply transport to a platform runtime or implement facets directly. @@ -72,7 +72,7 @@ The `retired-platforms-zone` gate rejects the old root path. - 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. Conditional operations require parity evidence for correctness that depends on owner facts. + 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. @@ -699,10 +699,11 @@ Command execution ownership remains singular. Review the behavior-neutral package/registry substrate 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. -A unit that changes `SessionState` classifies each changed field once under R7. +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. @@ -942,7 +943,7 @@ The snapshot route shares 30 hops, with 17/19 per arm: in-simulator AX bridge pr 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. +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`. From 6ce339f7ae40125adbea8ef387567d35fd3a2127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:10:46 +0200 Subject: [PATCH 41/52] docs(adr): restore scope words found by the 12-ADR drift pass ADR 0021: restore the two "so" consequences. ADR 0023: name `isEligible` as the fallback condition, and describe the adb-host binding instead of a permission. ADR 0025: limit "never promoted to a verified pose" to the `screen` evidence. ADR 0026: name the ledger as the shape to extend, not an order. ADR 0027: restore "carry no code", "new code", and "all". ADR 0029: restore the "HTTP edge" term. ADR 0032: restore "only", "one more", the "and" between edge kinds, and the "because" link. --- docs/adr/0021-host-simlock-managed-device-allocation.md | 6 +++--- docs/adr/0023-end-state-hop-trace.md | 4 ++-- docs/adr/0025-foldable-apple-panels.md | 2 +- docs/adr/0026-scroll-clip-authority.md | 2 +- docs/adr/0027-descriptor-root-vs-eager-closure-budget.md | 8 ++++---- docs/adr/0029-daemon-policy.md | 6 +++--- docs/adr/0032-layering-graph-engine.md | 9 ++++----- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/adr/0021-host-simlock-managed-device-allocation.md b/docs/adr/0021-host-simlock-managed-device-allocation.md index ac56086644..b294ff1d61 100644 --- a/docs/adr/0021-host-simlock-managed-device-allocation.md +++ b/docs/adr/0021-host-simlock-managed-device-allocation.md @@ -71,9 +71,9 @@ agent-device does not verify that a device is leased. Neither system depends on the other. - The CLI and daemon accept no per-request Android adb server port. One daemon uses one adb server. - Agents with leases from different Simlock daemons use separate state directories. - Node SDK `agent-device/android-adb` functions use the caller's adb executor. - The caller configures that executor for Simlock's 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 diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index e98ca1d020..7aea2ed62d 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -127,7 +127,7 @@ The terminal invokes host-kit's shared `runCmd` in `packages/host-kit/src/intern 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`. -ADR Decision section 1 permits only the named composition module to bind this named root file. +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. @@ -183,7 +183,7 @@ The route branches at `packages/platform-apple/src/snapshot-route.ts` through `r The in-simulator AX bridge is primary. The XCTest runner is fallback under any of these conditions: -- The capture is ineligible. +- `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`. diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index fb624bba7b..2928b95144 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -293,7 +293,7 @@ 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` and never proves pose. +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 diff --git a/docs/adr/0026-scroll-clip-authority.md b/docs/adr/0026-scroll-clip-authority.md index b1df922095..00817ff420 100644 --- a/docs/adr/0026-scroll-clip-authority.md +++ b/docs/adr/0026-scroll-clip-authority.md @@ -110,7 +110,7 @@ Each source index must end presented with its representatives or removed with a 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. -Extend that ledger. A parallel ledger would create another source of truth. +That ledger is the shape to extend. A second parallel ledger would be a second source of truth. ## Refuted alternatives 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 1299874742..08f7911b98 100644 --- a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md +++ b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md @@ -99,8 +99,8 @@ Three facts determine eligibility from the closure graphs and merge-base tree, w 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 contain no detected transplant from a source deleted since the merge-base. - Two or more identical normalized top-level statements shared with one deleted source constitute a move signal. + 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. @@ -127,7 +127,7 @@ Formatting and comments change bytes without changing those statements. The guarantee is exactly these facts: -- Newly evaluated paths are absent from the merge-base tree. +- 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. @@ -188,7 +188,7 @@ A statement-neutral split is shippable, so an oversized hub is addressable debt, A proxy improvement does not justify loss of a correctness guarantee. - **Reduce descriptor verbosity instead of splitting.** This is legitimate but unexamined. - It changes declaration vocabulary for approximately 80 commands, exceeds the split's scope, and needs a separate decision. + It changes the declaration vocabulary for all approximately 80 commands, exceeds the split's scope, and needs a separate decision. ## Recovery diff --git a/docs/adr/0029-daemon-policy.md b/docs/adr/0029-daemon-policy.md index d1a804b62d..a78b9fc943 100644 --- a/docs/adr/0029-daemon-policy.md +++ b/docs/adr/0029-daemon-policy.md @@ -74,10 +74,10 @@ Operators need to confine a remote agent to one simulator and prevent the agent 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: -- Internal `batch` steps and `replay` actions do not cross HTTP again. +- 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 boundary sees only the name. + The HTTP edge sees only the name. - `close --shutdown` and lifecycle close reach the same shutdown capability as `shutdown`. ## Decision @@ -150,4 +150,4 @@ Its digest identifies that policy. 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 the HTTP boundary. + If added, it must decide at the same three points, not at the HTTP edge. diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 4a97123682..97b379982b 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -36,7 +36,7 @@ Retrieve the harness with `git show d9959f510:scripts/layering/boundary-engine-s 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 on exactly the tracked production tree: 1,837 files and 41 zones. +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 `.fallowrc.json` field count also refer to files at that commit. @@ -53,7 +53,7 @@ The deletion table and `.fallowrc.json` field count also refer to files at that 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/type-only edges. +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: - 33 dynamic → type. @@ -65,7 +65,7 @@ Thus, `typeof import('./x').f` and `await import('./x')` retain whichever appear 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` and adds an R6 finding. +`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. @@ -147,8 +147,7 @@ The current per-pair count ratchet permits that replacement. ## Decision -Retain the current engine. -The evaluated replacements lack sufficient semantic parity and net simplification. +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. From 02caf40d01ecc4b2b25fcc37c8e1a5e126330a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:09:19 +0200 Subject: [PATCH 42/52] docs(adr): restore ADR 0022 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0022-daemon-platform-runtime-coupling.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index a5dc4106ad..94ed5119b5 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -103,7 +103,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted R7/R10 logical-module declarations own cross-module reads outside this handler path. R75 does not ratchet those reads. -4. **The repeated hop trace assigns roles and tests deletion.** +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. @@ -145,6 +145,7 @@ The ADR 0019 trace recorded routes of approximately 24 files without an accepted - **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. From 9b4654416144af0ca504bd7d984d5cfb4629d188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:09:19 +0200 Subject: [PATCH 43/52] docs(adr): restore ADR 0023 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0023-end-state-hop-trace.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index 7aea2ed62d..3dd528e9c8 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -17,7 +17,7 @@ Reference trace for the entry-to-platform hop counts in ADR 0019 and the couplin 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 for `press`/Android and 29 for `snapshot`/iOS without an ordered route, definition, or linked artifact. +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`. @@ -349,7 +349,7 @@ They were already in effect when 38/29 was recorded. 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 files on the path. +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. From d7f3a5d74fdb30eb3bceb264f23a6984e0e1907a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:09:52 +0200 Subject: [PATCH 44/52] docs(adr): restore ADR 0031 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0031-macos-native-app-backend.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0031-macos-native-app-backend.md b/docs/adr/0031-macos-native-app-backend.md index 5ed08bc3f7..e4b337e694 100644 --- a/docs/adr/0031-macos-native-app-backend.md +++ b/docs/adr/0031-macos-native-app-backend.md @@ -15,7 +15,7 @@ Electron, Catalyst, and wheel delivery with window fields remain unmeasured. 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 runner-only operations at admission: + For every macOS session, `macOsNativeBackendFacts` refuses every runner-only operation at admission: - Recording. - `prepare`. - `back`. @@ -67,7 +67,7 @@ Drags and WKWebView button clicks arrived only after private focus-without-raise Calculator and Electron accepted background keyboard events. A dropped post still reports success. -An unverified event fallback could therefore report success when no action occurred. +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.** @@ -116,4 +116,4 @@ A persistent pointer would require a long-lived helper. - **Native by default.** Accessibility cannot express drags, holds, or double-clicks. Apps with sparse accessibility trees still require the runner. - Default changes require coverage evidence across app frameworks. + Defaults change only with coverage evidence across app frameworks. From 63ea1e60ab7c125a2d3b2890e4b09f74bf3c7244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:09:52 +0200 Subject: [PATCH 45/52] docs(adr): restore ADR 0032 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0032-layering-graph-engine.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0032-layering-graph-engine.md b/docs/adr/0032-layering-graph-engine.md index 97b379982b..1c7af1a081 100644 --- a/docs/adr/0032-layering-graph-engine.md +++ b/docs/adr/0032-layering-graph-engine.md @@ -38,7 +38,7 @@ It derives both engines' R5/R6 rules from `model.ts`'s `RANKED_ZONES` and `zoneR 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 `.fallowrc.json` field count also refer to files at that commit. +The deletion table and the `.fallowrc.json` field count are counts of files at that commit. ### Edge-set diff against `resolveImportEdges` From 44521bddb31b1b8a15246a146084baa4657c9267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:09:52 +0200 Subject: [PATCH 46/52] docs(adr): restore ADR 0033 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0033-collocation-decision-table.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index a067302fa5..1590bbb700 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -16,7 +16,7 @@ Re-evaluate those files against their remaining edges. ## Rules at a glance -- A `keep` row records a decision. +- 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. @@ -41,7 +41,7 @@ Re-evaluate those files against their remaining edges. | `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. 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/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. | @@ -157,7 +157,7 @@ They are not independent proof that the placement is optimal. | `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 changes to both declarations. The rules constrain the move without independently proving placement. | +| `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. | @@ -180,6 +180,7 @@ Git history retains the completed batch. ## Follow-ups (tracked on #3281) +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. @@ -220,7 +221,7 @@ Composed root tests reach the daemon through `startDaemonRuntime` from the entry 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. - The decision rejects the sequence, not the destination. + This entry rejects the sequence, not the destination. ## The enduring rule From d1ac2e2206ab228247b580e6ae0fd67b36940edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:11:10 +0200 Subject: [PATCH 47/52] docs(adr): restore ADR 0025 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0025-foldable-apple-panels.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 2928b95144..36349b92f8 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -93,7 +93,7 @@ It is the only reader of CoreDevice display information. 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, which downstream consumers trust. +It exits 0 with a valid black PNG. Every downstream consumer trusts that capture of nothing. | Capture path | Before | After | | --- | --- | --- | @@ -402,7 +402,7 @@ 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 uses the geometry-preserving preset at both quality tiers. +`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. @@ -466,7 +466,7 @@ The session closed and its daemon stopped after the run. 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. +Hinge readback determined pose, not the requested preset. The amendment above makes hinge readback the only defensible source. Two initial inner-panel sessions requested open but read back 130°. They therefore serve as corroboration; the settled 180° session supplies the table. @@ -596,7 +596,7 @@ The silent misses investigated here came from default outer-display routes and i 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. - All observed runs settled within budget. + 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. From 153cfdd92489cde56544f6b1e8e30bda12937835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:11:10 +0200 Subject: [PATCH 48/52] docs(adr): restore ADR 0026 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0026-scroll-clip-authority.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/adr/0026-scroll-clip-authority.md b/docs/adr/0026-scroll-clip-authority.md index 00817ff420..b70547701b 100644 --- a/docs/adr/0026-scroll-clip-authority.md +++ b/docs/adr/0026-scroll-clip-authority.md @@ -41,7 +41,7 @@ That inference determined output membership. 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 iOS scroll host is regular-eligible. +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: @@ -57,15 +57,15 @@ Both contain these types: - `TextView` - `WebView` -**Same-frame traversal preserves structural ownership.** +**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 traversal is necessary because a `WKWebView` page indicator is published under `WebView` wrappers that exactly fill its `ScrollView`. +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 traversal must not restore #2214's failure. +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. @@ -155,13 +155,13 @@ That ledger is the shape to extend. A second parallel ledger would be a second s ## Consequences and open evidence -- **Same-frame traversal resolves the observed under-clipping.** +- **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 traversal reproduces `main` clipping on all three captures. + 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. From 11f98f7060e54fbf4d68116e1bee6d11bd55dc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:11:11 +0200 Subject: [PATCH 49/52] docs(adr): restore ADR 0027 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. --- docs/adr/0027-descriptor-root-vs-eager-closure-budget.md | 1 + 1 file changed, 1 insertion(+) 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 08f7911b98..75286abbd1 100644 --- a/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md +++ b/docs/adr/0027-descriptor-root-vs-eager-closure-budget.md @@ -179,6 +179,7 @@ A statement-neutral split is shippable, so an oversized hub is addressable debt, 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. From a9d05b03de964258d8e6280fb90a5cbcae44e7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:14:28 +0200 Subject: [PATCH 50/52] docs(adr): restore ADR 0019 wording dropped by the rewrite Found by the second meaning-drift pass against base 59fdf008b9. Also repair the review instruction that pointed at the deleted substrate paragraph. --- .../0019-request-bound-platform-runtime.md | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 0ec25aaedb..a825c62316 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -22,16 +22,16 @@ 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 through #2070–#2072 removes capability buckets and the legacy execution shape. +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/**`. -R66 enforces facts-only admission and runtime proof without historical route or handler names. -Section 6 required deletion of the completed cutover table. +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 puts all six families' implementations and family-owned tests behind private package exports. +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 the old root path. +The `retired-platforms-zone` gate rejects any attempt to recreate the old root path. ## Rules at a glance @@ -42,19 +42,19 @@ The `retired-platforms-zone` gate rejects the old root path. - 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. -- Each canonical family has one local runtime owner. +- 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 through facets. + 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 authoritative and fail-closed. +- 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 bindings, and daemon-session durable resources have separate lifetimes. +- 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. @@ -68,7 +68,7 @@ The `retired-platforms-zone` gate rejects the old root path. 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 require the lifecycle evidence in sections 4–5. + 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. @@ -223,7 +223,7 @@ Its workspace compliance includes `check:affected` selection, R11/R13 package en > > `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 production, test, and fixture files under the former `src/platforms` path. +> `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. @@ -355,8 +355,8 @@ Absence of the member for a whole admitted operation remains an ownership bug an That absence is never the mechanism that expresses an unsupported shape. **A subject of an operation may carry its own fact.** -An operation may serve a closed vocabulary with unequal owner support, such as `setSetting` over the settings catalog. -Its available fact may name a fact per subject and an `unlisted` refusal for every other subject. +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. @@ -382,7 +382,7 @@ The narrowed type does not intersect a wide optional aggregate that would still 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 **runtime-facet-narrowing gate** covers every runtime-migrated handler owner. +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. @@ -599,7 +599,7 @@ Contracts first validate a neutral envelope with these fields: - Lifecycle state. Only after exact-owner selection does the facet's total codec decode the descriptor body. -Invalid or unknown envelopes/bodies remain recovery evidence with typed `descriptor-invalid`/`descriptor-version-unsupported` outcomes. +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. @@ -632,7 +632,7 @@ 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 require it. +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. @@ -649,7 +649,7 @@ Its neutral record contains these values: 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. -Cleanup uncertainty cannot be a terminal lifecycle. +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. @@ -687,7 +687,7 @@ A temporary parametrized cutover table proved that each unit deleted its old ada 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 and restored legacy 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. @@ -696,7 +696,7 @@ Either these mechanics remain physically in place until their last consumer can Composition injects that capability into both the package facet and the complete legacy adapter. Command execution ownership remains singular. -Review the behavior-neutral package/registry substrate and each command unit from a clean committed tree with all production files present in `HEAD`. +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. @@ -745,7 +745,7 @@ These tiers remain review vocabulary for future runtime changes. They do not res Completion retired the cutover table. Facts are the only support authority for device commands. -Permanent R66 policy rejects descriptor capability buckets, `requireCommandSupported` calls, and manufactured or repaired narrowed runtime proof. +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 @@ -797,7 +797,7 @@ Persisted durable manifests and platform-owned markers are the only triggers for 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, for example the Android test-IME marker and web browser orphans, moves to this pattern when its owning domain migrates, not earlier. +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: @@ -805,7 +805,7 @@ Daemon shutdown uses two gateway phases: 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. -Daemon runtime no longer calls platform-specific shutdown directly. +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. @@ -816,7 +816,7 @@ Each domain owns its cleanup step: - Close-time cleanup consumes neutral owner services. R65 enforces the daemon end state through planted-red AST tests. -It rejects static, dynamic, re-export, and type-only dependencies from production `packages/daemon/src/**` to `src/platforms/**` and concrete `@agent-device/platform-*` packages. +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. @@ -915,7 +915,7 @@ The completed repository retains only closed execution shapes and permanent stru 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 in-memory session store already supplies the process-local home. + 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. @@ -1002,4 +1002,4 @@ The number 14 remains a historical discussion reference, not a proposed commitme CLI output and SDK session normalization remain application adapters under `src/`. Neither number has an owning issue, gate, or accepted budget yet. -Both remain inputs to a maintainer decision, not a ratchet. +Treat both as inputs to a maintainer decision, not as a ratchet. From 9a061e9ec7733e7c6633f6ee566270fd33139407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:17:03 +0200 Subject: [PATCH 51/52] docs(adr): restore the ADR 0021 claim guard wording Base: "device claims still guard against two sessions ... driving the same device". "prevent" claimed a stronger guarantee. --- docs/adr/0021-host-simlock-managed-device-allocation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0021-host-simlock-managed-device-allocation.md b/docs/adr/0021-host-simlock-managed-device-allocation.md index b294ff1d61..0e35e01d65 100644 --- a/docs/adr/0021-host-simlock-managed-device-allocation.md +++ b/docs/adr/0021-host-simlock-managed-device-allocation.md @@ -50,7 +50,7 @@ agent-device open MyApp --platform ios --ios-simulator-device-set "$SIMLOCK_IOS_ ``` The lease provides exclusion between agents. -Process-owned device claims still prevent two sessions of one installation from driving the same device. +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. From 17d21027408b02747e1f557efdd3204eefcece1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:32:39 +0200 Subject: [PATCH 52/52] docs(adr): scope the ADR 0025 defensible-source claim to the recorded poses Base: "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". https://github.com/callstack/agent-device/pull/3731#discussion_r4301716108 --- docs/adr/0025-foldable-apple-panels.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 36349b92f8..045d7a5191 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -466,7 +466,7 @@ The session closed and its daemon stopped after the run. 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. +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.