Make iOS regular snapshot eligibility one backend-neutral presentation rule. Acquire tree nodes conservatively, preserve interactive scroll containers, normalize surviving hierarchy, and keep raw membership plus daemon publication policy unchanged. Part of #1797. - iOS and macOS unit-enabled runner builds - 2 focused XCTest cases - 3 production-path publication tests - live Settings snapshots: 73 regular nodes and 167 raw nodes, both healthy tree captures
50 KiB
Agent Device Domain Context
Durable vocabulary for this repo. Use these names in code, tests, issue titles, and architecture notes rather than coining parallel ones. You rarely need the whole file — jump to the section your task touches:
- Sessions, targets, devices
- Command surface & routing
- Interaction, refs, and guarantees
- Gestures & touch
- Snapshots & capture
- Recording & replay
- Maestro compatibility
- Providers, cloud, and the test harness
- Architecture — the command-descriptor baseline and staged platform-runtime seam
- Selector capture reliability contract — invariants any capture refactor must preserve
- Testing principles
Terms
Sessions, targets, devices
- Interactor (legacy): monolithic semantic interface between dispatch and platform behavior, retained only for commands not yet migrated under ADR 0019. Avoid for new or migrated command behavior.
- Platform family: one internal ownership axis in the canonical registry (
apple,android,harmonyos,vega,linux, orweb). Family ownership does not imply uniform support across its leaves, device kinds, or providers. - Platform leaf: concrete OS/device shape within a family whose support is classified independently, such as iOS simulator, physical iOS, tvOS, or macOS within Apple.
- Platform module: private
@agent-device/platform-*package that owns one family's device mechanics behind a metadata-eager, implementation-lazy façade. - Implementation laziness: platform-module property where family metadata is cheap to compose while discovery mechanics, runtime implementations, and helper managers load only when that family is first discovered or bound.
- Device inventory gateway: platform-neutral composition of canonical local-family and provider inventory sources. It discovers and classifies devices before any selected-device binding exists.
- Device runtime gateway: platform-neutral seam that reports runtime facts and binds one admitted device to its selected runtime owner.
- Runtime owner: exactly one local platform module or provider runtime selected to execute device behavior for an ownership-qualified device.
- Request binding: request-lived attachment of cancellation, diagnostics, progress, and admitted context to a runtime owner. It never owns a helper manager, healthy helper generation, or adopted durable resource.
- Bound device runtime: behavior-bearing view returned by a request binding after the required operations in a command's runtime use are proven.
- Runtime facet: capability-cohesive interface on a bound device runtime, with normalized semantic inputs and typed outcomes rather than command names or daemon payloads.
- Runtime fact: typed claim about behavior available for one exact platform leaf, device/backend, and provider mode.
- Narrowed bound runtime: compile-time projection exposing required facets non-optionally, explicitly preferred facets optionally, and no undeclared facets; handlers do not cast missing proof into existence.
- Host capability: narrow authority injected into a platform module for host process execution, diagnostics, progress, or resolved native assets; it carries no daemon request or session policy.
- Target: selected automation destination, such as mobile, tv, or desktop.
- Modality: broad supported device family, such as mobile, tv, or desktop.
- Session: daemon-owned state for a selected target and opened app or surface.
- Device lease: logical remote ownership of one selected device for a tenant/run/client and lease provider, separate from platform helper process locking.
- Device key: stable provider-scoped device identity used for lease contention, such as a simulator UDID, physical device id, or provider inventory id.
- Lease provider: remote connection source that routes and owns a device lease, such as
proxy, cloud bridge, orlimrun. - Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is not the remote client ownership boundary.
- Device claim: host-global exclusive ownership of one local device, held by an open session or by a single sessionless device-mutating command. Local only; remote targets use device leases instead.
- Device-claim policy: required command-descriptor trait declaring a command's relationship to the
claim store (
none,observe,require-owner,transient-exclusive,acquire-session,release-session). The request-execution scope enforces it at the device binding seam:transient-exclusivetakes a command-scoped claim and refuses a foreign one, and every other policy performs no claim-store I/O. - iOS physical-device control: Apple-local module selected from discovery evidence. CoreDevice
devices retain the
devicectlcontroller; devices found only byxctraceuse the XCTest controller for readiness, app activation/termination, and cable-bound usbmux runner transport without claiming unsupported app inventory or installation capabilities. - Host process primitive: low-level host PID helpers in
src/utils/host-process.tsfor liveness, start-time/command reads, process listing, process-tree expansion, PID de-duplication, and best-effort signaling. It must not own domain cleanup policy such as browser ownership markers, runner lease reclamation, daemon takeover checks, or app-log PID metadata verification.
Command surface & routing
- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints.
- Runtime use: platform-neutral declaration on a command descriptor containing operations required for admission and, separately, preferred fast paths whose absence does not reject the command.
- Inventory use: platform-neutral declaration for an inventory command that composes canonical local family and provider inventory sources without fabricating a selected-device binding.
- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution.
- Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior,
distinct from the public command surface and daemon command registry. The Swift runner traits
classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the
alert command as read-only only for its
getaction. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. - Daemon RPC protocol version: integer advertised by daemon/proxy
/healthand checked by remote clients before HTTP JSON-RPC; bump only for breaking transport/request/response compatibility across the remote daemon boundary. - Version-skew invariant: a client replaces any local daemon whose version or code signature
differs (
isReusableDaemonInfo, both directions), so local client↔daemon skew cannot exist. Version-skew compat code is legitimate on exactly four axes and its comment must name one: the remote daemon boundary (the protocol version gates breaking changes only — older remote daemons silently ignore additive optional fields), separately versioned runner/helper binaries, persisted artifacts (.ad/config/env/session logs — handle or refuse with migration guidance, forever), and released API consumers. "Older local daemon" tolerance is dead code.
Interaction, refs, and guarantees
- Interaction dispatch path: one concrete route an interaction command takes to the device (runtime selector/ref resolution, direct iOS selector, native ref via web clickRef, coordinate, maestro non-hittable fallback). Every path classifies every guarantee in the ADR 0011 registry.
- Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector
or text query resolves the semantic
XCUIElement, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoidingXCUIElement.tap()post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven. - Parent-owned touch point: runtime ref/selector activation keeps the resolved parent identity but, when its center belongs to an independently interactive descendant, moves the coordinate to the nearest region with bounded clearance from those child controls. The exact center remains the zero-cost default when no child competes; a fully tiled parent fails closed so a child must be named.
- Guarantee cell: one (dispatch path, guarantee) entry in
packages/contracts/src/interaction-guarantees.ts, classified as runtime/runner/delegated/inapplicable/waived. Completeness is a compile error; honesty is gate-tested. - Owned waiver: a
gap:-prefixed waived cell carrying atrackingIssueURL. Waivers are diffable debt with an owner, never folklore. - Delegation-on-error: a fast path falling back to the runtime path on semantic failure shapes. It closes failure-side guarantee cells only — never success-path parity.
- Parity table: golden JSON fixture under
contracts/fixtures/consumed by both vitest and the runner's gated Swift tests, so a cross-language rule (e.g. tap-point policy) cannot drift silently. Change the rule only via the table. - Coverage manifest:
CONTRACT_COVERAGEexport beside each interaction contract test file claiming which matrix cells it proves; the coverage gate requires every enforced/delegated cell to be claimed and rejects overclaims of waived cells. - Ref frame (ADR 0014): the session's single authorization namespace for mutation
@refs, kept separate from the latest operational observation (session.snapshot). It owns a frozen epoch (therefsGenerationthe client received), an immutable source tree, a lifecycle state (active/expired), and an issuance scope (allfor a complete snapshot, or the bounded set of ref bodies a partial publication emitted). Owned solely bysrc/daemon/ref-frame.ts. A complete snapshot activates anallframe;find/settled diff/replay divergence activate a bounded partial frame that supersedes the prior one; internal read captures never activate or reindex it. Replay captures return opaque, one-shot lineage evidence, and daemon response composition activates refs synchronously only after the exact inline or successfully written overflow projection is known. Every finalization attempt consumes its evidence. The outer replay retains its stable session lock plus the device lock when known through finalization, so external commands cannot interleave; nested replay actions reuse that scope and invalidate lineage through capture, ref-frame, side-effect, or session-lifetime changes. - Frame expiry seam (ADR 0014): every mutating leaf calls
expireRefFramesynchronously, immediately before the device op that may change element identity (after all pre-action guards), so a post-dispatch failure still leaves the frame expired — there is no success-only rollback. Ref resolution binds@eNagainst the frame's source tree, so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; a fresh capture's coordinates are adopted only when its node's local identity matches. - Mutation admission (ADR 0014): a ref mutation is admitted only against an active frame whose epoch
and issuance scope authorize the ref (
admitRefMutation, order-sensitive reasonsref_frame_expired→ref_generation_mismatch→plain_ref_requires_complete_frame→ref_not_issued). Rejections carrydetails.reasonand name the lifetime failure. A ref-oriented sequence that performs several mutations must re-observe (snapshot), consume an honestly issued settled ref in pinned form, or use selectors. Read-only ref consumers stay fail-open with a staleness warning while the frame retains the ref's evidence. - Ref generation pin: optional
~s<n>suffix on an @ref carrying the snapshot generation it was minted from. Accepted as input everywhere, emitted by no tree output (snapshot token budget), auto-appended by the MCP layer, stripped and ignored by replay. - Deferred interaction outcome: the daemon's post-response answer to "did that mutation actually
take effect" — pending interaction outcome retry, post-gesture stabilization, and Android
snapshot freshness recovery.
src/daemon/deferred-interaction-outcome.tsis its one interface: every mutating route marks through it after dispatch, and every snapshot capture resolves through it; the threeSessionStatefields stay with their R7 owners (the module itself ownspostGestureStabilization, so the seam adds no node to the R9 cycle). Marking order is load-bearing (pending outcome retry before stabilization), each marker keeps its own eligibility gate, and the module owns only these post-action markers — never ADR 0014 ref-frame expiry or the ADR 0012/0016 staged protocols. Distinct from the same-response settled observation below. - Settled observation: opt-in (
--settle) post-action payload on press/click/fill/longpress and, on the generic route, scroll/back — the quiet-window stable loop re-captures until the UI settles, and the response carries the diff vs the pre-action tree (changed lines only, added lines with fresh refs,refsGenerationwhen the settled tree was stored). Best-effort: never fails the action;settled: falseplus a hint on never-quiet content. Which commands support it is a descriptor trait (postActionObservation), and the CLI flags, MCP fields, timeout envelope, and ref-pinning all derive from it. The two routes differ in ONE way, deliberately: the touch commands diff against the freshly resolved pre-action capture, while scroll/back — which resolve nothing — diff against the session's stored pre-action tree, so their diff reads "settled tree vs the last tree you observed". - Resolution disclosure (ADR 0012 decision 2): additive
resolutionfield on press/click/fill/longpress responses discloses how the acting path resolved its target —runtime/uniqueorruntime/disambiguated(withmatchCount/winnerDiagnostic/tiebreak/ up-to-5alternatives) on the daemon tree,ref/exactfor a resolved@ref(runtime-ref and native-ref),ref/label-fallbackwhen runtime-ref recovered a stale@refvia its recorded trailing label, ordirect-ios/not-observedon the XCTest fast path; absent entirely on the coordinate path and on dispatches whose runner actually executed the maestro non-hittable coordinate fallback (permission alone keeps the direct path'snot-observed). Pre-action diagnostics only:winnerDiagnostic/alternativesentries carry an opaque, non-@diagnosticRefthat is never ref-issued, never MCP-pinned, and cannot be reused as an@reftarget — a fresh snapshot/find is required before acting on an alternative.
Gestures & touch
- Gesture plan: typed, platform-neutral normalization of one- or two-contact gesture intent into bounded pointer trajectories. Contact topology is separate from motion; two-contact intent remains pan/pinch/rotate/transform even when native injection shares one executor. See ADR 0013.
- Android planned-touch executor: Android-local adapter seam that accepts
AndroidTouchPlan— the platform-neutralGesturePlanplus Android's stationary long-press plan — and selects the paired provider-native touch/viewport adapter or bundled instrumentation-helper adapter. Scroll and long-press retain their command semantics and only share physical touch execution through this seam. Helper long-press executes its absolute stationary path without a viewport probe; provider long-press receives its paired provider-owned viewport. See ADR 0013. - Multi-touch geometry: the internal initial span and angle plus centroid translation, scale, and rotation used to build both contact trajectories. Geometry is viewport-aware and fails early when the requested motion cannot fit; it is not a public tuning surface.
Snapshots & capture
- Raw AX node: backend-owned iOS acquisition value before the snapshot presentation boundary. During the #1797 migration it temporarily carries existing derived fields so the seam can land without a wire-behavior change.
- Snapshot acquisition: the result of one iOS snapshot backend attempt—raw AX nodes plus
attempt-level truncation, depth, and custom-action facts. Every capture-plan backend returns this
type, and the exhaustive backend switch routes it through
SnapshotPresentationexactly once. - Presentation options: the source-of-truth iOS snapshot request policy accepted by
SnapshotPresentation. During the behavior-preserving #1797 migration acquisition still reads these options to reproduce current backend-specific decisions. - Snapshot eligibility: runner-presentation membership for the regular projection. A root carrier survives for viewport geometry; every other node needs an interactive accessibility type or a non-empty label, identifier, or value. Hittability is not eligibility, and raw membership is exempt. Removing a wrapper reparents surviving descendants and normalizes indexes and depths. Daemon compaction separately owns publication membership and its declared suppressions.
- Presented node: wire-facing iOS snapshot value constructed only by
SnapshotPresentation; response payload assembly accepts this type rather than backend-owned raw values. - Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch.
- Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes.
- iOS WebView semantic presentation: the interactive snapshot projection that recognizes XCTest's
typed
WebViewroot and WebKit'sOther -> StaticTextwrapper pairs. It keeps raw diagnostics unchanged, presents ordinary wrapper text asStaticText, and presents wrappers carrying WebKit's numeric HTML heading level asHeading. - AX-unavailable target invalidation: iOS/macOS runner behavior where a root accessibility snapshot
failure such as
kAXErrorIllegalArgumentmarks the cachedXCUIApplicationtarget handle suspect. The runner fails closed for degraded interactive snapshots, clears the cached target, and lets the next command reacquire the app through normal activation.
Recording & replay
- Script recording: opt-in session mode armed before actions so a persisted
.adcan carry portable action inputs and recording-time target identity evidence. It is distinct from screen/video recording. - Recorded input parameterization: explicit fill authoring contract that sends literal text only to
the live interaction while the recorder stores
${VAR}before any durable recording/event/publication boundary. The caller owns the uppercase variable name; no selector or field-name heuristic infers sensitivity. Replay resolves the placeholder immediately before dispatch and preserves the authored placeholder if that run is recorded again. - Open-to-destination script: self-contained
.adscript with exactly one initialopen, a destination guard after its last app-state mutation, noclose, and an app session left active for subsequent work. Avoid: replay (artifact noun), fragment (reserved for lifecycle-free composition), partial script. - Destination guard: portable selector-targeted
waitnear the end of an open-to-destination script that confirms a landmark on the ready destination screen before replay hands the live session to its caller. - Replay script source bundle: every script file one
replay/testrun needs, read and resolved by the CALLER and shipped inside the request — an entry display path plus a resolved-path-to-text map covering the.adscript or the Maestro flow and itsrunFlowincludes. The daemon executes only what the bundle carries and resolves no caller path, so a local run and a run against a remote daemon read identical bytes (#1802). Avoid: script upload, flow payload. - Screen-recording facet: runtime facet that starts platform screen/video capture and returns a live handle plus its durable descriptor. It is distinct from script recording.
- Live resource handle: process-local authority to finish or forcibly dispose active app-log,
screen-recording, or profiler work through outcome-bearing
finish/forceCleanupoperations; itsAsyncDisposableadapter rejects when cleanup is unconfirmed. A neutral contract handle may live in R7-ownedSessionState; it is never serialized into persisted recovery state. - Durable resource descriptor: bounded, versioned, persistable identity and recovery state from which the same runtime owner can deterministically reattach, recover completion, or report a typed missing/unreattachable outcome.
- Reattachment: fenced recovery attempt by the descriptor's exact runtime owner, returning a live handle, completed result, missing state, or typed unreattachable reason without restarting the resource or falling through to another owner.
- Recording backend (legacy): daemon-selected tag-to-implementation interface retained only for screen-recording commands not yet migrated under ADR 0019. Avoid for new behavior.
Maestro compatibility
- Maestro program: source-preserving typed representation of supported Maestro YAML. It is interpreted directly through the compatibility runtime port and never lowered through generic replay action strings. See ADR 0015.
- Maestro observation generation: explicit compatibility-engine state identifying evidence captured since the most recent mutation. Queries may share semantic evidence within one generation; every mutation attempt invalidates it before dispatch. Interaction geometry is action-local: unique exact iOS selectors resolve and tap atomically in XCTest, while coordinate dispatch uses a fresh target snapshot. Rectangles are never shared across command boundaries.
Providers, cloud, and the test harness
- Provider: external device/runtime adapter that may own a complete device runtime or contribute a typed transport to a platform module; ownership is resolved and bound per request.
- Provider-backed integration scenario: device-free integration test that runs the real daemon request path and replaces only external device or host tool execution.
- Cloud WebDriver runtime: direct provider runtime owner that maps a cloud-owned Appium/WebDriver session into agent-device leases, inventory, runtime facts/facets, durable resources, and release without provider-specific daemon branches. Its exact facts differ from local Apple/Android runtimes because snapshots come from Appium page source.
- CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation
logs, or provider dashboard links. Cloud artifacts stay under the
cloudArtifactsresponse field so they do not collide with daemon-managed local/downloadableartifacts. - DaemonArtifactType: optional semantic category supplied by the command or adapter that owns a
daemon-managed downloadable artifact, such as
screenshot,screen-recording, ortrace-log. Finalization and inventory code must preserve this value when present, not infer it from filenames, fields, or MIME types. Missing artifact types must not prevent artifact registration. The type documents known values while allowing provider or command owners to introduce more specific strings. - Provider transcript: exact record of provider calls used when a test must verify platform command translation.
- Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands.
- In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener.
- HTTP contract test: narrow test that verifies JSON-RPC transport, auth, and response finalization over the daemon HTTP boundary.
Architecture
ADR 0011 (interaction guarantee contract) is the interaction-semantics counterpart of ADR 0008's
registry thesis: the dispatch-path × guarantee matrix is declared once in
packages/contracts/src/interaction-guarantees.ts, completeness is type-enforced, honesty and coverage are
gate-enforced, and cross-language rules are pinned by golden parity tables. New dispatch paths and
guarantees are whole-matrix decisions, not local edits.
The 2026 command/registry refactor is the enforced baseline. ADR 0019 keeps the command-descriptor axis and stages a deeper platform-runtime seam, one abandonment-safe command cutover at a time:
- Command and platform axes. One
CommandDescriptorper command (src/core/command-descriptor/registry.ts) is the single declaration site from which the public/internal/local command catalog, capability matrix, daemon command registry, batch allowlist, timeout policy, MCP exposure list, capability-checked CLI command list, post-action observation traits, and platform dispatch command set are derived by parity-tested projection. Command families still own surface metadata/CLI schema insrc/commands/**, but descriptor/catalog coherence guards prevent surface names from drifting; system command facets now project their simple Node client command methods. Closed public Node-client result contracts are narrowed throughCommandResultMap; action/backend-dependent methods remain explicitly broad until their public response projections are reconciled. See Node client result types. The current shallowPlatformPluginregistry remains the complete legacy adapter for unmigrated commands. ADR 0019 replaces that axis command by command with an immutable, metadata-eager/implementation-lazy platform-module registry: descriptors declare inventory or device runtime use, runtime owners report exact facts and behavior-bearing facets, and only the root composition module imports concrete packages. See ADR 0008 and ADR 0019. - Typed result spine. Per-command typed results replaced the ad-hoc
Record-typed returns across the daemon/dispatch path; errors gained machine-readableretriable/supportedOnsignals onDaemonError(#939). Error-system conventions live in ADR 0010. - Apple platform model. Internally
Platformisapple(plusandroid/harmonyos/vega/linux/web) with anappleOsdiscriminant (ios | ipados | tvos | watchos | visionos | macos); the shared Apple engine lives undersrc/platforms/apple/core/with per-OS leaves undersrc/platforms/apple/os/<os>/. The public wire stays non-breaking:PUBLIC_PLATFORMS(packages/kernel/src/device.ts) still emitsios/macosleaf output. See ADR 0009. - Vega platform model. Initial Vega OS support is deliberately VVD-only: discovery returns
VirtualDevice, and platform capability admission rejects physical Fire TV devices until durable hardware evidence validates discovery, lifecycle, and the complete remote-control contract. Vega capture, selector, inventory, install, logging, and performance backends remain separate follow-up surfaces. - Folder DAG + layering lint.
scripts/layering/check.tsenforces five rules across four scopes in CI. GLOBALLY, across every production source file, it enforces the R1-R3 move rules (kernel-sink, commands-floor, platforms-seam) and rejects all production static value-import cycles. R1-R3 are declared as data inscripts/layering/zone-policy.ts(ZONE_POLICIES): which zones a boundary governs, which import kinds it tolerates, and which path prefixes are its declared seam. A fourth zone boundary is a table entry, not a fourth predicate.zone-policy.test.tsasserts each boundary fires and each exemption holds — necessary because the tree is clean, so a rule that stopped matching would look exactly like a rule being obeyed. Separately, it ranks an explicit target spine — as rank groups, lowest (kernel sink) to highest, whereA ◄ Bmeans B may not be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff } ◄ core ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli(the former rank-0 kernel zone lives inpackages/kernelsince #1490 W0, and shared selectors now live behind the private@agent-device/selectorspackage between@agent-device/ad-scriptand@agent-device/ad-replay; the formercloud-webdriverleaf lives behind the single@agent-device/provider-webdriverfacade since W1b, Limrun lives behind the single@agent-device/provider-limrunfacade since W1d, and the dependency-free XML codec lives behind the single@agent-device/xmlfacade; R11 package-boundaries owns these physical seams) — and rejects every back-edge within it. Only(root)is unranked amongsrc/zones (UNRANKED_ZONESinscripts/layering/model.ts): it holds the entrypoints and the composition roots that wire the command surface into the daemon, and R2 forbidsdaemon/from importingcommands/, so those files sit outside the spine by construction. Extracted workspace packages are classified separately and enforced by R11. The satellite zones used to be unranked too, on the grounds that ranking them would invent an order the architecture had not committed to; onceutilsjoined the spine and(root)was emptied of shared contracts, every one of them turned out to have a consistent rank already.model.test.tsguards that no new zone escapes this classification silently. Thirdly, R6 ratchets the SAME inversion measured over TYPE-ONLY edges, which R5 ignores by design: a type-only import is free at runtime, but "zone A is declared in terms of zone B" is still a boundary claim, and ranking type edges surfaced 61 inversions the gate had never seen.TYPE_INVERSION_BASELINEincheck.tsholds the remaining pairs with their counts; the numbers may only shrink, and a new pair fails outright. Down to 7, and each is a deliberate position rather than a misplaced declaration: 4 areAgentDeviceClientused as an opaque handle (the facade is built fromcommands/'s own projection registry, so moving it down is a design call about where that registry belongs, not a file move), and 3 are the ADR 0003 daemon descriptor, whose route type iskeyof typeof DAEMON_ROUTE_HANDLERS— derived from what the server actually implements. Both are explained at the baseline. - SessionState ownership (R7).
SessionStore.get()returns the live record out of a private Map andset()re-puts the same reference, so anysession.<field> = …in the daemon is an immediate write to store-owned live state — visibility depends on aliasing, not on an API call, and the map is not rehydrated across daemon restart. That is workable while each field has an owner that keeps its invariants, soSESSION_STATE_FIELD_OWNERS(scripts/layering/session-state.ts) records them and the gate stops the set from growing quietly: a new field must declare an owner, a foreign write fails with the owner to call instead, and an owner that stops writing must be removed. The classification is exhaustive — every field is either in that table or inSTORE_OWNED_SESSION_STATE_FIELDS, the positive claim "the store establishes this and nothing mutates it later". Without that parity a new field with no direct write would satisfy the gate by being invisible to it, and R7 would silently stop covering part of the type it covers. A direct write to a store-established field fails, naming both remedies. Detection follows session records through aliased bindings (nextSession,provisionalSession,completedSession), not just a local namedsession: matching the literal name is what let three genuine foreign writes sit unreported. Since there is no type information in the gate, the binding test is a name test paired with the declared-field filter — a provider or runner session only registers if it also writes a fieldSessionStateowns, and the remedy is then the same. ADR 0014's ref frame and the snapshot lineage are the worked examples: the frame's four fields moved together across two modules untilactivateRefFrametook the transition, andsnapshotScopeSource+snapshotGenerationwere assigned insnapshot-runtime.tsuntilsetSnapshotLineagetook theirs. - Type-cycle size (R9). R4 keeps the VALUE import graph acyclic, so every remaining cycle is
created by type-only imports — free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a strongly-connected component of 46 files,
no file has a self-contained slice.
TYPE_CYCLE_BASELINE, derived from the zone ceilings inscripts/layering/daemon-modularity.ts, pins it by equality: growth fails, and so does a baseline left above the measured size, which is headroom the next change spends without a reviewer seeing a number move. A shrink is recorded by lowering the zone ceilings in the change that earns it (#1781 A6; the rule was growth-only until then, and reported the slack as a suggestion). Hubs by in-component dependents:core/dispatch.ts(8),command-catalog.ts(7),commands/interaction/runtime/resolution.ts(6),core/command-descriptor/registry.ts(6). The former type hubs (runtime-contract.ts,commands/runtime-types.ts,backend.ts,commands/runtime-common.ts) left the cycle when #1632 sankbackend.ts's two upward type imports — 27 files stranded out of the component at once. - Daemon modularity ratchets (R10). The same tooling-only declaration pins R7's writer-owned
field/owner-claim counts, R9's 46 members by zone (
commands14,daemon-server16,core10,platforms2, root 3,client1), and the external production importers ofdaemon/types.ts(down to 2: the client normalizers and remote artifacts). R7 counts and external importers may only shrink; no zone may grow inside R9, and replay/Maestro/replay-test engine files remain outside it. The per-zone ratchet stays stricter than R9's total: even moving cycle membership into a zone at its ceiling must be justified by lowering another ceiling or changing the baseline explicitly. The #1478 extraction arc (P0–P5) completed against these ratchets: engines live behind thepackages/{maestro,replay-test,ad-replay,selectors}façades, engines cannot import daemon/platform/provider implementations, and no logical module may deep-import another module'sinternal/tree. The P6 platform-modularity phases were measured and deferred at the #1478 checkpoint (2026-08-04); HarmonyOS then supplied the additional real adapter pressure that earned ADR 0019's staged platform-runtime migration. Its substrate plus completedevices/logs/networkcheckpoint must validate the seam before any further command migration. These are ratchets, not permission to scaffold façades before a real seam has two adapters. - Platform package boundary (R13). ADR 0019 has exactly six private
@agent-device/platform-*package façades and one root composition file,src/platform-runtime.ts. R13 pins that total registration, forbids contracts-to-platform, sibling-platform, root/daemon, and raw-process edges in every import form (including tests), and keeps package façades metadata-eager but inventory/runtime mechanics lazy. Composition cannot probe tools, prepare assets, or construct helpers. Each rule has a planted-red structural case; R11 still owns the general workspace exports/dependency boundary. bin.tsalias fast path (R12).bin.ts --helpresolves a command alias before looking up static help text, and it must do that through the one alias registry (commands/cli-command-aliases.ts) rather than a table of its own: the hand-written table it once carried fell out of sync and silently droppedtap/launch/relaunchoff the fast path. R12 reads three structural facts out ofbin.ts's source — a value import ofnormalizeCliCommandAlias, no registry alias token as a local string literal, and everybuildCommandUsageTextcall receiving the resolver applied to the fast path's own help-target binding. The universal form is the point: an existential one passes on a decoy call while the shipped call runs raw.bin.tsdispatches on import and is excluded from coverage, so no unit test can reach this.- Contracts implementation authority (R18).
packages/contractsowns vocabulary, so its production source may not importchild_process,fsortimers, call a timer primitive, or grow parser mechanics undernetwork-traffic— host, process and lifecycle mechanics belong in@agent-device/capture-kitor an adapter. oxlint's restricted-import rule coverschild_processundersrc/**only, which is neither this package nor these modules. - Selector pipeline ownership (R19). The structural stages of selector resolution (occlusion,
off-screen, hittable-ancestor promotion, poll budget) are declared per caller in
core/selector-pipeline-policy.tsand run bycore/selector-pipeline.ts; a route that reaches the matching engine directly inherits a row's ambiguity contract while skipping every stage (#1649, #1656). R19 admitscore/selector-pipeline.tsas the only importer of@agent-device/selectors/engine, keyed on the specifier over the resolved graph so a namespace import, a re-export and a deferredimport()are all the same edge. If that subpath stops being exported the rule fails loudly rather than going quiet, because an unresolvable specifier drops out of the graph. - Zero-dep CI jobs (R8) — retired (#1781 A6). Jobs running with
install-deps: falsehad nonode_modules, a constraint no local run can feel, so R8 walked each such job's entry scripts and required every specifier to be a Node builtin or another repo file. #1490 W0 removed the last one andci.ymlrecords why each remaining job keepsinstall-depsenabled, leaving the rule with no subjects; R11's relative-into-packages/exception, which existed only because a zero-dep closure cannot coexist with specifier loads, retired with it. The number is spent: reintroducing the constraint means a new rule with the next free id. - Agent-cost. Responses carry a cost block and MCP
outputSchema, rendered through a leveledResponseView.
Principles and their gates
The architecture rules this repo runs on are Clean-Architecture-shaped. Some are fully gate-enforced, some carry an open ledger that only shrinks, and some are norms with local evidence but no gate yet — each bullet below says which. When judging a design change, argue from the rule; when landing it, satisfy the gate where one exists.
- Dependency Rule (source dependencies point toward policy; details depend on abstractions).
Gate: layering R1–R3 import direction plus the ranked spine's no-back-edges check
(
scripts/layering/check.ts). #1405's "shared contracts below their consumers" is dependency inversion stated as a merge gate. Ledger:TYPE_INVERSION_BASELINE— type-only inversions where types still flow the wrong way; the ratchet only tightens, and the long-term target is zero. - Acyclic components. Gate: R4 bans value-import cycles globally. Type-only and dynamic-import cycles are deliberately tolerated by the gate; a report surfacing them as data is proposed in #1410 (the analysis half of the graph tooling, kept after the #1409 viewer was rejected) and is not landed yet.
- Policy × detail boundaries on demonstrated axes of change. The two demonstrated axes are
CommandDescriptorinventory/runtime use (what the system needs) × inventory sources or runtime-owner facts/facets (how a family or exact device can do it), ADR 0008/0009/0019. The shallowPlatformPluginremains only as the legacy command adapter during staged adoption. Boundary-crossing enforcement is narrower than the principle: the apple-platform leak guard (publicPlatformString, provider-integration suite) gates one specific DTO class — internalapplenever reaching serialized public output — and the injectable Apple runner transport (runnerProvider, #1389) is one adopted seam, not a rule covering every boundary. Wider DTO/seam coverage is direction, not current enforcement. - Information hiding. Gate: R7 — every
SessionStatefield is classified and every write must occur inside its declared owner. Encapsulation of the one shared mutable object, enforced per-field. This coversSessionState; other shared state has no equivalent gate today. - Boundaries are earned, not speculative. Norm with local evidence, not a gate: the platform descriptor layer (~600 LOC of boundary nobody needed) was deleted, and the depgraph viewer (#1409) was closed unmerged. A new abstraction layer needs a demonstrated second consumer or axis of change. The one gated slice of this norm is tests: CI forbids test-only DI seams — a missing seam gets added as a real one or not at all.
- Module seams (the #1478 extraction's durable rules). State crosses seams as immutable
values, authority crosses seams as capabilities, and both only narrow; a capability seam earns
its port only when it has two real adapters, normally the daemon adapter and a deterministic
adapter running the same contract suite. A pure shared kernel instead stays behind its direct
package façade; the selector engine is the worked example. Calls go down through module façades
and back through ports; no inter-module event bus — current diagnostics/session events, and the
ADR 0018 journal if accepted, are observation channels rather than coordination mechanisms. No
engine receives
DaemonRequest,DaemonError,SessionStore, mutableSessionState, provider handles, or concrete platform implementations; the daemon adapter closes each capability over one already-admitted request, so an engine cannot express a session name, acquire a lock, or select a provider scope. Session state keeps three consistency disciplines distinct and never forces them through one generic transaction: immediate pessimistic transitions for ref/observation lineage (ADR 0014's mid-request expiry is why end-of-request commit/rollback is rejected), staged arm/complete/close-succeeded/commit-or-abort protocols with operation-keyed receipts for repair/publication (ADR 0012/0016), and append-only facts for recorded actions and diagnostics. Gates: R10 zero-count module policies, R11 package boundaries, the façade symbol pins, and R7 ownership. - Tests couple to stable interfaces. Norm (see Testing Principles) backed by the test-only-DI-seam gate above; broader test-strength enforcement is planned, not present — tracked under #1412.
- Component metrics are observatory data, never gates. Instability/abstractness per zone is a proposal (#1423, building on #1410's graph model) to locate concrete, high-fan-in modules worth pinning harder — explicitly never a CI threshold.
Deferred
The completed command/registry baseline retains these deferred follow-ups. ADR 0019's staged platform-runtime migration is an active accepted decision, not part of this list:
- Dynamic Node-client results — interactions, observability, alert, React Native overlay, and settings remain broad until their action/backend-specific payloads have accurate public projections. See Node client result types.
- Legacy alias drops — ~175 LOC of legacy aliases/barrels remain, gated to the next major.
Selector Capture Reliability Contract
Selector capture is allowed to optimize transport, helper reuse, and polling, but it must preserve the observable freshness and failure semantics below before any runtime refactor.
- Direct iOS selector queries are a narrow fast path only: iOS, simple one-term
id/label/text/valueselectors, and never whilepostGestureStabilizationis pending. A direct miss may fall back to the snapshot selector path, but ambiguous matches and runner errors must surface instead of silently falling back.get textuses direct native selectors only for simpleidselectors because label/text/value reads need snapshot disambiguation. - Regular selector reads remain capture-backed.
@refs resolve against the authorized ref frame's source tree (ADR 0014), not whatever now sits at that index in a newer observation; selectorget/is/find/waitcapture through the backend.findandwaitpolling must bypass the 750 ms snapshot cache. The cache is also bypassed while Android freshness recovery or post-gesture stabilization is active. - Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace
session.snapshot, and selector routes should report the sparse verdict instead of treating a root-only or sparse tree as an empty UI. The user-facingsnapshotdispatch publishes a fallback screenshot through the response artifact channel (fallbackScreenshotPath); internal observations never do, so a polling wait cannot turn an unreadable screen into one screenshot per poll. - iOS sparse and AX failures are not proof of empty UI. Regular visible snapshots can recover through
the capture plan; raw and strict paths preserve failure.
runnerFatalinvalidates the cached target and must never refresh healthy mutation recency. - An
XCTEST_RECORDED_FAILUREafter an iOS tap is an ambiguous outcome, not proof that the tap missed. The daemon may take one same-presentation post-action capture against a usable retained snapshot; only a changed accessibility digest converts the result to success with a warning. Capture failure, sparse or mismatched presentation, and an unchanged digest remain failures so corroboration cannot turn an unknown tap into a false success. - Android helper reuse must not become snapshot result caching. Freshness is short lived, marked only after navigation-sensitive actions, compared against broad route-safe baselines, and not learned from scoped, depth-limited, interactive, or ref-refresh snapshots.
- Pending interaction outcome retry runs before post-gesture stabilization. Android freshness then composes when needed. Stabilization applies after swipe, scroll, gesture, or an explicit flag, and disables direct iOS selector shortcuts while pending.
setSessionSnapshotis the centralized session snapshot mutation path. Sparse captures do not write back, and empty@ref-scoped snapshot output must not replace the stored session snapshot.- Maestro target matching remains snapshot-based and policy-owned. Coordinate dispatch always uses a fresh target snapshot. A unique exact iOS match may instead reuse bound same-generation semantic evidence and dispatch through XCTest's atomic selector tap; structured live-selector failures return to fresh Maestro resolution. This optimization must not erase Maestro regex/string selector behavior, visibility filtering, provider-order first-match selection, explicit index selection, or assertion/wait semantics. Provider normalization belongs below the compatibility layer. Plain text is exact and regex-aware; do not add substring/fuzzy recovery, synthetic geometry, or hierarchy-shape heuristics that change authored selector meaning.
Evidence: ADR 0002,
ADR 0004,
ADR 0005,
ADR 0015,
find.test.ts,
snapshot-handler.test.ts,
snapshot-scoped-refs.test.ts,
runtime-targets-typed.test.ts, and
android-test-suite.test.ts.
Testing Principles
- Provider-backed integration scenarios should exercise the public daemon path whenever practical.
- Prefer the in-process provider scenario harness for broad scenarios; keep HTTP contract tests narrow and transport-specific.
- Transport providers sit below a platform module; direct provider runtimes sit beside local family owners. Both run the same runtime contract scenarios through the public daemon path so provider coverage still exercises the appropriate device-command translation.
- Provider transcripts are for exact external command contracts.
- Scenario transcripts are for broad, user-rooted workflows that should replace mocked handler unit tests.
- Unit tests stay for pure logic, parser matrices, selector matching, capabilities, and important edge cases.
Gate selection, speed rules, and shared fixtures live in docs/agents/testing.md.