Files
callstack__agent-device/CONTEXT.md
T
Michał Pierzchała 309a5360f5 feat(daemon): add the allocator-held device claim kind (#2263)
* feat(daemon): add the allocator-held device claim kind

ADR 0021 foundations, unit 2. A device an allocator-managed pool owns is held for that identity's
whole pool lifetime, not for a session, so the claim store gains a second record kind whose
principal is an installation instead of a process. Nothing writes one in production yet.

- `AllocatorHeldDeviceClaim` is a SEPARATE record at `schemaVersion: 3` with `kind: 'allocator'`:
  `stateDir` + `allocator.instanceId` + `allocator.identityIncarnationId`, and no
  ownerPid/ownerStartTime/ownerToken/session/workspace/abandonedAtMs — a record carrying any of
  them does not decode. `DeviceClaim` and its v2 files are untouched, so an older daemon never
  meets a changed process-owned record and only ever sees a v3 file for a managed identity, which
  it reads as an unreadable claim record and fails closed on.
- The managed owner is DERIVED from the recorded allocator instance (`allocatorHeldClaimOwner`),
  never stored, so an owner that disagrees with the claim's principal cannot exist.
- `InspectedDeviceClaim` becomes a union: the allocator member carries `allocatorClaim` and
  `claim: undefined`. Every clearing surface — ownership match, abandon, stale release, the
  startup sweep, acquire-path reconciliation, session close, lease expiry, the shutdown ledger,
  and `processOwnsActiveDeviceClaim` — reaches for `claim`, so none of them can be written against
  an allocator-held claim, and `DeviceClaimReconciler` stays typed to the process-owned record.
- New classification `allocator-held`: not stale, not owner-releasable. `device status` shows it
  in the normal view with its allocator, incarnation and installation; `device release --stale`
  refuses it with `allocator-held-owner`.
- The verifier gains `covered` and `incarnation-stale`. A session or transient command whose fence
  names the incarnation the claim holds executes under it, acquiring and clearing nothing; a fence
  for a re-provisioned identity is refused, because an incarnation is stable for that identity's
  pool lifetime.
- The ORDINARY arm of the admission gate now performs a read-only inspection under every policy
  but `none` and refuses `DEVICE_IN_USE` / `DEVICE_CLAIM_ALLOCATOR_HELD`: `apps` and `app-state`
  boot a device through `ensureReady` exactly as a mutation would. It still acquires nothing, so
  the policy/claim-file table is unchanged. That reason is deliberately outside
  `DeviceClaimConflictReason`, so replay never retries it as infrastructure. The inspection reads
  the record and stops (`readAllocatorHeldClaimFile`): the owner-liveness probe `inspectDeviceClaimFile`
  runs costs a host process observation per binding, and this kind has no owner process to observe.
- `acquireAllocatorHeldDeviceClaim` reattaches only on the full principal and never reconciles or
  supersedes an ordinary claim (a conflicting ordinary claim prevents publication, ADR 0021 §4);
  `releaseAllocatorHeldClaim` takes removal proof and is the only clearing path. Both are
  by-design production-unused in this unit, with a comment naming the unit that will call them.
- The record/decoder and the lock/write leaves move out of device-claims.ts into
  device-claim-record.ts and device-claim-store.ts, which dissolves the
  device-claims <-> device-claim-inspection type cycle and lets the allocator module share the
  writer without device-claims.ts ever importing it.

`devices` still reports no `claimedBy` for an allocator-held claim: the public field is
`{ session, workspace }` and this kind has neither.

* docs: condense CONTEXT.md entries to leave room for sibling units

Wording-only tightening of 41 existing definitions. No term is added, removed or redefined, and the
five entries unit 1 added and the three unit 2 touched are left alone. The glossary ends at 11,589
of the 12,000-byte guidance budget, which leaves room for the sibling unit's vocabulary to land
without a second trim.

* fix(daemon): register device-claim-store as the claim file's atomic-publish owner

device-claims.ts no longer writes its claim file directly: it delegates every write to
writeDeviceClaim in the new device-claim-store.ts, the single writer shared by the process-owned
and allocator-held claim kinds. atomic-publish-ownership.test.ts's SIMPLE_PUBLISHERS list still
named device-claims.ts, so its source-grep for `publishFileSync` no longer matched anything and
the ownership gate failed in CI. Register device-claim-store.ts as the owner instead.

Also rename DeviceClaimRecord -> StoredDeviceClaim (and decodeDeviceClaimRecord ->
decodeStoredDeviceClaim): the shutdown ledger's own DeviceClaimRecord in daemon-shutdown-report.ts
is a different type (one row of what teardown released), and the shared name invited confusing
that unrelated type for this module's claim-file record.

* fix(daemon): extend the allocator-held admission exhaustiveness table

The rebase onto adr0021/u1-owner-kind's decideAllocatorHeldAdmission fix carried forward
ADMITTED_BY_OUTCOME_STATUS and its outcomes array from before this branch added the covered and
incarnation-stale statuses, so the table no longer covered the whole AllocatorHeldClaimAdmission
union and TS2739 caught it. Add both: covered admits, incarnation-stale does not.

* fix(daemon): fail closed on a corrupted allocator-looking claim record

readAllocatorHeldClaimFile gated on entry.allocatorClaim, which is only set once a record
decodes all the way through. A v3-schema record corrupted into also carrying a process
principal field fails decodeAllocatorHeldClaim's carriesProcessPrincipal check and
decodeStoredDeviceClaim returns null -- exactly like a record that never existed. Ordinary
admission's inspectAllocatorHeldDeviceClaim then read that as "no allocator claim" and let
observe and every other non-transient-exclusive policy proceed, against a device an
allocator may actually hold.

A record that declares schemaVersion 3 but fails to decode is not provably a non-allocator
record, so it cannot be treated as absent. Add looksLikeAllocatorHeldClaim (device-claim-
record.ts) to distinguish "declares the allocator schema, doesn't decode" from every other
kind of corruption, give it its own classification (allocator-inconsistent), and have
readAllocatorHeldClaimFile return the entry -- not null -- for it. Ordinary admission then
refuses through the existing deviceClaimConflictError path (DEVICE_IN_USE /
DEVICE_CLAIM_OWNER_UNCERTAIN), same as any other claim it cannot verify.

deviceClaimRequiresStaleInspection, deviceClaimOwnerCannotRelease and conflictReason are
exhaustive switches over DeviceClaimClassification, so the new member forced a decision at
every site rather than one that could be missed: not stale (nothing dead to surface), not
owner-releasable (no process proof exists to make), and DEVICE_CLAIM_OWNER_UNCERTAIN like
the other undecodable-record classifications, not a permanent-condition reason.

Planted-red verified: reverting readAllocatorHeldClaimFile's gate to entry?.allocatorClaim
alone makes the new regression fail with error.code 'UNKNOWN' -- admission resolves with no
error at all, exactly the silent pass-through this fixes.

* refactor(daemon): fold staleReleaseRefusalReason into a lookup table

The switch exceeded fallow's health gate at 10 cyclomatic / 31.6 CRAP once the
allocator-inconsistent case joined it. A Record<DeviceClaimClassification, string> reads as
one branch to the complexity walker instead of one per case, while TypeScript still refuses
to compile a missing key -- the same exhaustiveness guarantee a switch gave, at a fraction
of the counted complexity.

* docs: trim CONTEXT.md back under the 12,000-byte guidance budget

Main gained bytes elsewhere since this stack's own docs-condense commit landed, pushing
CONTEXT.md to 12,028. Tighten four of this stack's own entries (managed local owner, request
generation, identity incarnation, managed device allocator port) rather than touch anyone
else's; 11,947 bytes leaves headroom against the next PR that lands first.
2026-09-03 19:41:53 +02:00

12 KiB

Agent Device Domain Language

Canonical vocabulary for the automation domain. Use these names in code, tests, issues, and architecture notes; implementation decisions and procedures belong in ADRs and task guidance.

Language

Sessions, targets, and devices

Platform family: An internal ownership group for related automation platforms: Apple, Android, HarmonyOS, Vega, Linux, web.

Platform leaf: A concrete OS and device shape within a platform family, classified independently: iOS simulator, physical iOS, tvOS, or macOS.

Platform module: A private package owning one platform family's device mechanics, metadata, and runtime bindings.

Device inventory gateway: The platform-neutral composition of local-family and provider inventory sources.

Device runtime gateway: The platform-neutral boundary that reports runtime facts and binds an admitted device to its runtime owner.

Runtime owner: The one local platform module, managed local owner, or provider runtime selected to execute behavior for an ownership-qualified device.

Managed local owner: The exact-only runtime owner for an allocator-managed local device; it delegates automation to the device's platform module while lifecycle stays with the allocator. Avoid: Managed provider, provider runtime

Request binding: A request-lived attachment of cancellation, diagnostics, progress, and admitted context to a runtime owner.

Bound device runtime: The view returned once a request binding proves the required runtime operations.

Runtime facet: A capability-cohesive interface on a bound device runtime with semantic inputs and typed outcomes.

Runtime fact: A typed claim about behavior available for one exact platform leaf, device or backend, and provider mode.

Narrowed bound runtime: A projection exposing required facets, optional preferred facets, and no undeclared facets.

Host capability: Narrow authority given to a platform module for host execution, diagnostics, progress, or native assets.

Target: The selected automation destination, such as mobile, TV, or desktop.

Session: Daemon-owned state for one selected target and its opened app or surface.

Device key: A stable provider-scoped identity for device ownership and contention.

Device lease: Logical remote ownership of a selected device for a tenant, run, or client.

Lease provider: The remote connection source that routes and owns a device lease.

Runner lease: A mutual-exclusion guard for a platform helper process. It is not remote client ownership. Avoid: Device lease, process lease

Device claim: Host-global exclusive ownership of one local device by an open session, a sessionless mutating command, or an allocator-held claim for a managed identity.

Allocator-held claim: A device claim whose principal is an installation and an allocator identity incarnation rather than a process; sessions and commands execute under it, and only the allocator's removal proof clears it. Avoid: Stale claim, session claim, synthetic session

Device-claim policy: A command's observation, ownership, or exclusive-mutation rule.

Device-claim rule: The per-owner-kind decision at the claim gate: ordinary, allocator-held, or none. Avoid: Claim policy, device-claim policy

Managed binding fence: The ownership fence of one managed binding: requester and identity incarnation as its token, request generation as its generation.

Request generation: The per-requester monotonic number of one allocation attempt on a lane; never shared across requesters.

Identity incarnation: The allocator-issued id of one creation of a managed identity, stable for its pool lifetime and preserved across Android clean-baseline reuse; a fresh iOS identity is a new device with a new incarnation, and a different incarnation on a claimed device is a conflict. Avoid: Request generation

Human-control hold: A device-scoped pause on agent mutations during human operation.

Commands and routing

Command surface: The catalog of public command identity, interface exposure, adapter policy, and metadata across entrypoints.

Runtime use: A command's platform-neutral declaration of required operations and optional preferred fast paths.

Inventory use: An inventory command's platform-neutral declaration for composing device sources without binding one.

Daemon command registry: The daemon-side truth for route ownership and request-policy traits.

Runner command traits: Per-command classifications controlling Apple runner lifecycle and recovery behavior independently of the public command surface.

Daemon RPC protocol version: The integer used to detect breaking compatibility across the remote daemon boundary.

Version-skew invariant: Local client and daemon versions must match; only remote daemons, separately versioned helpers, persisted artifacts, and released API consumers get compatibility handling.

Interactions, selectors, and refs

Interactor: The legacy monolithic interface between dispatch and platform behavior, kept only for unmigrated commands. Avoid: New or migrated command behavior

Interaction dispatch path: One route an interaction command takes from a resolved target to device execution.

Coordinate-first resolved element activation: An Apple interaction that resolves a semantic element and activates its resolved center point without a second lookup.

Parent-owned touch point: A point that keeps the selected parent's identity while avoiding independently interactive descendants at its center.

Guarantee cell: One dispatch-path-by-guarantee classification: enforced, delegated, inapplicable, or waived.

Owned waiver: A guarantee gap with a tracking issue and explicit owner.

Delegation-on-error: A fast path that returns semantic failures to the shared path; it establishes failure-side handling, not success-path parity.

Parity table: A golden rule table consumed by both TypeScript and native tests.

Coverage manifest: A contract test's declaration of the guarantee cells it proves.

Ref frame: The session's authorization namespace for mutating @ref targets: a frozen observation epoch and issuance scope.

Frame expiry seam: The point just before a mutating device operation where the active ref frame becomes invalid.

Mutation admission: The decision that an active ref frame's epoch and issuance scope authorize a ref mutation.

Ref generation pin: An optional ~s<n> suffix carrying the snapshot generation an @ref was minted from.

Deferred interaction outcome: Post-response state recording whether a mutation still needs outcome retry, stabilization, or snapshot freshness recovery.

Settled observation: An optional post-action observation that waits for a quiet UI and diffs against the pre-action tree.

Resolution disclosure: Bounded response evidence describing how an interaction target resolved, issuing no new actionable refs.

Gestures and touch

Gesture plan: Typed, platform-neutral normalization of one- or two-contact gesture intent into bounded pointer trajectories.

Android planned-touch executor: The Android boundary selecting a provider-native or instrumentation-backed executor for a normalized touch plan.

Multi-touch geometry: The centroid, span, angle, translation, scale, and rotation that construct two-contact motion.

Snapshots and capture

Raw AX node: A backend-owned accessibility value before snapshot presentation.

Snapshot acquisition: One backend attempt's raw AX nodes and attempt-level capture facts.

Snapshot producer: The acquisition component that produced a snapshot's raw tree; presentation, scope, and geometry key on the producer, never on the platform channel alone.

Presentation options: The policy input turning one snapshot acquisition into a public projection.

Snapshot policy facet: The host-side owner of neutral snapshot policy (presentation, freshness, timeout, overlay); platform acquisition supplies raw facts and a fold policy, and runner-side Swift presentation stays separate across the process boundary.

Capture hint: The acquisition-facing view of a snapshot request: the projection a backend must serve, raw traversal depth kept apart from regular presented depth, and narrowing only where the backend can prove it complete.

Regular presented-depth frontier: The acquisition boundary for an unscoped regular snapshot, measured against regular presented depth after structural wrappers collapse.

Snapshot eligibility: Membership in a presented snapshot projection, independent of current hittability.

Clip fold: The regular projection's single visibility interpreter, run inside presentation for every backend: viewport and scroll-container clipping, ancestor projection, scroll hints, collapsed depth. Platform differences enter as a fold policy, never as a backend exception.

Presented node: A wire-facing snapshot value produced at the presentation boundary.

Snapshot capture plan: An ordered set of capture backends under one shared wall-clock budget.

Snapshot quality verdict: A structured statement of capture state, backend, degradation reason, effective depth, and collapsed content.

Snapshot projection: A view of one acquired tree. Interactive is a subset of regular, and regular is a subset of raw.

Declared capture residue: A fidelity limit in acquired evidence that presentation cannot repair and must disclose.

AX-unavailable target invalidation: The Apple behavior that discards a suspect cached application target after a root AX failure.

Recording and replay

Script recording: Session mode that captures portable actions and target evidence into a .ad script. Avoid: Screen recording

Recorded input parameterization: An explicit fill contract that sends literal text to the live app while storing a caller-chosen ${VAR} placeholder durably.

Open-to-destination script: A self-contained .ad script that opens an app, reaches and verifies a destination, and leaves the session active.

Destination guard: A selector-targeted wait near the end of an open-to-destination script verifying its ready state.

Replay script source bundle: The complete caller-resolved set of script paths and contents for one replay or test run.

Screen-recording facet: A runtime facet that starts video capture and returns a live handle and durable descriptor.

Live resource handle: Process-local authority to finish or forcibly dispose active logging, recording, or profiling.

Durable resource descriptor: Bounded, versioned identity and recovery state from which the same runtime owner can reattach.

Reattachment: A fenced recovery attempt by the descriptor's exact runtime owner returning a live handle, completed result, missing state, or typed refusal.

Maestro compatibility

Maestro program: A source-preserving typed representation of the supported Maestro Flow syntax and behavior.

Maestro observation generation: Compatibility-engine evidence since the most recent mutation; mutation invalidates it.

Providers and tests

Provider: An external adapter that owns a device runtime or contributes transport to a platform module.

Managed device allocator port: The daemon-owned interface to a managed-device allocator: obtain, hold, and give back a managed device. Avoid: Simlock client, lease provider

Cloud WebDriver runtime: A provider runtime mapping a cloud-owned Appium or WebDriver session into agent-device inventory, leases, runtime behavior, artifacts, and release.

Cloud artifact: Provider-hosted session output: video, automation logs, device logs, or dashboard links.

Daemon artifact type: An optional semantic category from the owner of a daemon-managed downloadable artifact.