mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
b2b084d2e1
AGENTS.md routed request cancellation/progress and diagnostics to `@agent-device/capture-kit` subpaths that no package exports; both live in `@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also named `@agent-device/contracts` as an importable seam although that package publishes no root export, and claimed `src/daemon/handlers/session.ts` was over budget after that extraction already landed at 242 lines. Two ADRs carried number 0019. The hop trace has its own claims to make, so it now numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022. The Node floor split was undocumented: `engines.node` stays at 22.12 because CI installs the published tarball on that floor, while contributors need 22.13 for the pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and the web backend's Node 24 requirement. Extend the agent-guidance contract to resolve every `@agent-device/*` specifier AGENTS.md names against the owning package's `exports`, root included, so neither a phantom subpath nor a phantom package root can route an agent to a module that does not exist.
937 lines
68 KiB
Markdown
937 lines
68 KiB
Markdown
# ADR 0019: Request-Bound Platform Runtime
|
||
|
||
## Status
|
||
|
||
Completed (2026-08-27). Accepted for staged adoption on 2026-08-09, with a successful checkpoint on
|
||
2026-08-10 and broader-migration governance added on 2026-08-11. The historical checkpoint and
|
||
per-unit evidence in sections 7–10 explain how the transition was kept shippable; they no longer
|
||
authorize or schedule migration work.
|
||
|
||
Amended 2026-08-26 while closing the Wave 6 residue: `host` covers host-scoped platform work through
|
||
a narrow neutral typed service, not diagnostics alone. Host diagnostics, daemon-owner resource
|
||
cleanup, and managed host tooling share the execution category because none binds a request-scoped
|
||
device runtime; their domain services remain separate rather than forming a generic host grab bag.
|
||
|
||
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 `src/daemon/**` 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.
|
||
|
||
## 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.
|
||
- During adoption, the first real slices paused for an explicit continue/revise/stop checkpoint.
|
||
Later units were move-dominated: platform mechanics left root `src/` for platform packages and
|
||
exceptional shipped-size growth required an individual justification.
|
||
- Evidence is tiered by what a unit imports: request-scoped device units prove facts, operations,
|
||
and parity cells; only durable-resource units carry the section 4–5 lifecycle evidence.
|
||
- A handler binds once with its execution use. Admission, `capabilities`, and doctor questions use
|
||
side-effect-free facts inspection; required-only declarations are the default, a preferred
|
||
operation requires a recorded measurement, and a conditional operation requires parity evidence
|
||
explaining why correctness depends on the owner fact.
|
||
- Cross-cutting facets land with their first consuming command unit. Daemon startup recovery is
|
||
evidence-gated, daemon shutdown is two-phase (detach, then stop), and session-teardown steps
|
||
belong to their owning domains — there is no generic lifecycle-hook API.
|
||
|
||
## 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.
|
||
|
||
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 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.
|
||
|
||
## Decision
|
||
|
||
### 1. Platform modules provide behavior through one composition seam
|
||
|
||
Each canonical family has one private `packages/platform-<family>` workspace package named
|
||
`@agent-device/platform-<family>`. 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:
|
||
>
|
||
> - `@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.
|
||
>
|
||
> 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 exactly the `./runner` and `./runner/test-host`
|
||
subpaths. 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 stays package-internal and `./runner/test-host` has one
|
||
vitest installer; 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.
|
||
|
||
#### 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:
|
||
|
||
- discovery loads mechanics only for a family whose inventory is requested;
|
||
- binding loads mechanics only for the selected runtime owner;
|
||
- process-lived helper managers are created on first use, not while composing the registry; and
|
||
- loading one family does not load an unrelated family's implementation graph.
|
||
|
||
Composition may capture inert configured paths eagerly. Filesystem/tool probing, asset preparation or
|
||
building, helper construction, and facts requiring a selected-owner probe remain lazy. The structural
|
||
suite must prove that unrelated implementations are not evaluated before selected discovery/binding,
|
||
and the built artifact retains its startup measurement. Passing a startup threshold alone is not a
|
||
substitute for preserving the loading shape; the tracking issue owns the exact probe and planted-red
|
||
procedure.
|
||
|
||
### 2. Runtime use joins facts and narrows the bound runtime
|
||
|
||
`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.
|
||
|
||
`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
|
||
|
||
For one device, provider resolution has three outcomes:
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 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<Use>` 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.
|
||
|
||
### 5. Durable resources are reattachable by the same owner
|
||
|
||
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<FinishOutcome>` for idempotent normal completion,
|
||
`forceCleanup(): Promise<CleanupOutcome>` for outcome-bearing forced cleanup, and idempotent
|
||
`[Symbol.asyncDispose](): Promise<void>` 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<void>` 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<CleanupOutcome>` 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.
|
||
|
||
### 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.
|
||
|
||
### 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.
|
||
|
||
### 8. Broader migration was move-dominated and evidence-tiered
|
||
|
||
The checkpoint and recordings budgets paid for machinery that later units reuse; they are not a
|
||
precedent for growth. Every post-checkpoint unit still defines its original-baseline cumulative
|
||
bound and immediate stack-base delta, but the default expectation inverts: a unit relocates
|
||
existing platform mechanics from root `src/` into platform packages and deletes superseded daemon
|
||
code, so its shipped-size delta trends to zero or negative. The unit's review reports root bytes
|
||
removed against package bytes added; net growth is exceptional and each contributing addition is
|
||
named and justified individually. Contract modules stay vocabulary-thin under the existing
|
||
capture-kit rule; per-unit type inflation is size growth and is reviewed as such.
|
||
|
||
#### W5 Android family accounting (PR #2117)
|
||
|
||
The W5 exact-head size report at `9fa2778` against `437465f37` reported these rounded CI values:
|
||
|
||
| Metric | Original baseline | W5 head | W5 delta |
|
||
| ------------------ | ----------------: | ------: | -------: |
|
||
| Raw JavaScript | 2.48 MB | 2.49 MB | +8.2 kB |
|
||
| Gzipped JavaScript | 835.0 kB | 831.2 kB | -3.9 kB |
|
||
| npm tarball | 958.5 kB | 959.6 kB | +1.1 kB |
|
||
| npm unpacked | 3.32 MB | 3.33 MB | +9.0 kB |
|
||
|
||
At source level, the moved Android production tree accounts for 456,492 B removed from
|
||
`src/platforms/android` versus 475,413 B in changed package targets, a +18,921 B relocation
|
||
delta; the new root host/facade seam adds 19,978 B. The published +9.0 kB unpacked result is the
|
||
package result, not a source-byte estimate. The increase is attributable to the named `mechanics`
|
||
closure, the injected root host composition, and preserving deferred helper loading while root
|
||
chunks are removed or repartitioned. The largest packed increase is the mechanics chunk; it is
|
||
offset by deletion or shrinkage of the superseded root Android chunks.
|
||
|
||
A smaller design that retained root re-export shims, a broad barrel, or ambient filesystem/process
|
||
access would reduce gross source movement but violate this ADR's package-closure,
|
||
no-compatibility-shim, named-facet, host-injection, and implementation-lazy constraints. This is
|
||
exceptional W5 accounting, not reusable checkpoint headroom; after rebasing on #2116, the
|
||
combined-family CI size result remains the authoritative current measurement.
|
||
|
||
Raw per-unit parity, planted-red, and size evidence belongs in #1739 and its PRs; this ADR retains
|
||
the decision, the evidence tiers, and the rule that each unit must justify exceptional growth
|
||
without treating checkpoint budget as an allowance.
|
||
|
||
A command surface deprecated for the next major was deleted rather than migrated. Where a public
|
||
surface was narrowed, the narrowing merged first so the migration denominator was the surviving
|
||
surface only.
|
||
|
||
Evidence requirements followed what a unit imported, in two tiers. A **request-scoped device unit**
|
||
used facts and operations only and proved its typed declaration and every fact cell. A
|
||
**durable-resource unit** additionally carried the full section 4–5 lifecycle evidence. These tiers
|
||
remain useful review vocabulary for future runtime changes, without reviving the migration ledger.
|
||
|
||
The migration-time cutover table was retired at terminal completion. Facts are now the only support
|
||
authority for device commands, and the permanent R66 policy rejects descriptor capability buckets,
|
||
`requireCommandSupported` calls, and attempts to manufacture or repair narrowed runtime proof.
|
||
|
||
### 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.
|
||
|
||
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`.
|
||
|
||
### 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 `src/daemon/**` 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.
|
||
|
||
## 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 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.
|
||
- [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.
|
||
|
||
## 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 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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
|
||
**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. `src/daemon/**` is a permanent zone under R65; the target is about what remains
|
||
there, not about retiring the directory.
|
||
|
||
**Entry-to-platform hop count.** Corrected 2026-09-03, 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.
|
||
|
||
**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) —
|
||
pixel-diff computation and PNG decode/encode already call capture-kit's `png`/
|
||
`png-worker-client`; the root files remain for daemon/CLI diff-report composition.
|
||
Equivalent: none (built atop already-migrated capture-kit mechanics).
|
||
- `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`, `src/metro`,
|
||
`src/backend*.ts`. 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.
|
||
|
||
Neither number has an owning issue, gate, or accepted budget yet. Treat both as inputs to a
|
||
maintainer decision, not as a ratchet.
|