Skip to content

Commit 54977f3

Browse files
thymikeeclaude
andauthored
feat(daemon): ADR 0014 session ref-frame lifetime — full implementation (#1257)
* feat(daemon): classify ref-frame effect on every daemon command (ADR 0014 step 2) Add the ADR 0014 `refFrameEffect` trait to the daemon command descriptor facet: every command that reaches a session-owning daemon leaf declares how it relates to the session's authorized ref frame — `preserve`, `may-invalidate`, `delegated`, or a request-sensitive resolver for subaction-dependent commands (keyboard status vs dismiss, alert get/wait vs accept/dismiss). This is the honesty/completeness guard, not the transition site: a `may-invalidate` command still calls the (future) ref-frame module only when its mutating path runs. No runtime behavior changes here. - `RefFrameEffect` / `DaemonRefFrameEffect` types and a `resolveRefFrameEffect` accessor honoring the resolver form, mirroring the existing closure traits. - Classify all 58 daemon-faceted commands; `find` is the honest superset (`may-invalidate`) pending a read/mutate resolver during enforcement wiring. - Give `app-switcher` a daemon facet (route unchanged) so the generic-fallback escape hatch the ADR calls out is covered instead of silently unclassified; drop it from parity's UNROUTED set. - Completeness gate (`ref-frame-effect.test.ts`): every daemon-projected command classifies an effect, every public command is classified or in the explicit non-daemon allowlist (`install-from-source`, which projects via the `install_source` internal command), and the resolvers/app-switcher resolve as declared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): introduce ref-frame module + admission matrix (ADR 0014 step 1) Introduce `src/daemon/ref-frame.ts` as the single owner of the ADR 0014 ref-frame model — the authorization namespace for mutation refs, kept distinct from the latest operational observation (`session.snapshot`). It defines the frame's issuance scope and lifecycle state and the pure mutation-admission matrix (`admitRefMutation`) with the ADR's typed, order-sensitive reasons: ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame, ref_not_issued. The frame is introduced behind the existing `snapshotGeneration` (epoch) and `snapshotRefsStale` (coarse client-stale) fields, whose wire-visible names (`refsGeneration`, the `@e12~s42` pin grammar) are unchanged. New `refFrameState`/`refFrameScope` session fields default to active/all, so the matrix currently reduces to the generation-pin check the iOS path already did — no behavior change. Expiration at the side-effect seam and non-`all` scope land in later steps. The existing #1241 iOS stale-ref guard now routes its decision through `admitRefMutation` (plus the transitional coarse-stale check for plain refs), so the module is production-live; the external error contract is identical. Adds a unit test covering the full admission matrix and reason ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): wire pre-side-effect frame expiration at the seams (ADR 0014 step 3) Route device mutations through the idempotent ref-frame transition. A leaf expires the current frame synchronously, immediately before awaiting the device operation, so success, timeout, cancellation, or connection loss all leave it expired — there is no success-only rollback. Seams wired: - interaction runtime backend closures (tap/click, fill, longPress, native web clickRef/fillRef, gesture, type) — post-resolution, pre-dispatch, so a resolution failure before the seam preserves the frame; - the generic daemon leaf (back/home/rotate/scroll/tv-remote/app-switcher/ viewport/focus, ...), gated by the daemon `refFrameEffect` classification via `resolveRefFrameEffect`, which is that resolver's first production consumer. Re-authorization: issuing a complete namespace re-activates the frame — `markSessionSnapshotRefsIssued` and the snapshot command's `buildNextSnapshotSession` — so a fresh capture between mutations restores usability. A diff or kept tree preserves the prior authorization state; internal read captures never re-authorize. Enforcement of the new expired-frame rejection is intentionally deferred to step 7, which the ADR gates on fresh live device evidence per platform. The iOS #1239 guard therefore stays armed-but-not-enforced here: it consults the admission matrix but still rejects only on the pre-existing conditions (pinned generation mismatch, coarse plain-ref stale marker), so behavior is unchanged. Tests prove the transition is wired (a press expires the frame; a re-issue re-activates it) alongside the idempotency and re-authorization unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix(daemon): address ADR 0014 review — partial issuance, keyboard, seam coverage Exact-head review found three blockers; all fixed with focused seam tests. 1. Partial issuance no longer restores complete authority. Every caller of `markSessionSnapshotRefsIssued` (find, settled diff, replay divergence) is a PARTIAL publication, but it re-activated a complete `all`-scope frame. It now only clears the coarse marker; complete re-authorization is reserved for the snapshot command (`activateCompleteRefFrame`, from `buildNextSnapshotSession`). 2. Keyboard resolver covers every mutating subaction. keyboard accepts status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return (enter/return dispatch a real return key) are now `may-invalidate`. Alert reads are likewise a named set. Completeness test extended. 3. Remaining step-3 leaf seams wired: the direct iOS selector fused dispatch, the direct `find` focus/type dispatches (find click/fill already delegate through the interaction leaf), and Android blocking-dialog recovery (expire before the recovery tap). Focused seam tests for each prove the frame expires. Enforcement of the expired-frame rejection remains deferred to step 7 behind the ADR's per-platform live-evidence gate; behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): cross the seam at every specialized mutating leaf (ADR 0014 step 3 complete) Wire expireRefFrame at the remaining may-invalidate leaves so EVERY mutating daemon leaf crosses the side-effect transition, not just the interaction/generic paths: - keyboard dismiss/enter/return, push, trigger-app-event (shared session leaf) — gated by resolveRefFrameEffect so keyboard status/get preserve the frame; - alert accept/dismiss (get/wait preserve, via the alert resolver); - settings mutations; - React Native overlay dismissal; - install / reinstall (deploy op); - open / relaunch — expires the reused session's frame before the launch; - close — expires for uniformity, though a successful close deletes the whole session (and its frame) anyway. Seam tests: keyboard dismiss expires while status preserves (proves the resolver-gated pattern), and RN overlay dismissal expires. Enforcement stays deferred; behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): partial issuance scope + MCP pin retention + pinned CLI refs (ADR 0014 step 4) A find/settled-diff/divergence result publishes only the refs it returned, so it now activates a bounded PARTIAL frame authorizing exactly those ref bodies (`markSessionPartialRefsIssued`) instead of nothing — a plain ref then requires a complete frame and a pinned ref outside the set is rejected. An empty partial result leaves prior authority intact. - read-only find publishes its one ref; settled diff publishes its added lines + `refs` + `tail`; divergence publishes its capped, non-covered, non-chrome digest set. - MCP: a mutating `find` returns no `refsGeneration` and is explicitly non-issuing — it no longer hits the missing-generation branch that wiped the whole per-session pin scope (forwarding the old pin is how the daemon produces a precise stale rejection). - Human-CLI partial results render reusable refs in ready-to-copy `@eN~s<gen>` form (find + settled tail); JSON/Node keep plain bodies + one response-level generation, and MCP stays plain (it auto-pins). Output-economy waiver covers the +8-byte tail-pin increase with an ADR justification; the workflow oracle treats a pinned ref as surfacing its plain body. Enforcement of the frame's expiry and partial-scope rejections stays deferred to step 7 (behind the ADR's per-platform live-evidence gate), so this is behavior-preserving; the iOS guard now consumes the admission verdict for a typed `details.reason` on the rejections it already emitted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): resolve refs against the authorized frame tree (ADR 0014 step 5) Retain the ref frame's immutable source tree (shared reference, no deep copy) and resolve a `@ref` against it rather than the latest operational observation. An Android freshness — or any read-only — capture advances `session.snapshot` without disturbing the frame tree, so the two intentionally diverge. At resolution, adopt the fresh observation's node (its current on-screen coordinates) ONLY when its local identity still matches the authorized node — the legitimate "element moved" case. If a different element now sits at that index, keep the authorized frame node so a positional coincidence cannot retarget the action. Expose the frame tree to the command runtime through `CommandSessionRecord.refFrameSnapshot`; pre-frame sessions fall back to `snapshot` and behave exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * feat(daemon): fail-closed ref-mutation enforcement across platforms (ADR 0014 step 7) Enforce the ref-frame admission matrix on every platform before dispatch: an expired frame, a superseded generation pin, a plain ref against a partial frame, or an unissued pinned ref is now rejected with a typed `details.reason` and an honest message that names the lifetime failure instead of claiming the ref was missing or lacked bounds. The prior iOS-only, coarse-marker guard is replaced. Freeze the frame epoch at issuance (`refFrameGeneration`) so a later read-only capture that advances the observation counter cannot falsely reject a correct pin from the issuing frame; staleness warnings compare against the same frame epoch. A mutating `find` re-resolves its target by locator against a fresh capture, so its internal leaf dispatch carries `internal.findResolvedTarget` and skips ref admission (it still crosses the seam and expires the frame). Update unit and provider-integration scenarios to the new contract: multi-mutation ref sequences re-observe between mutations, settled refs are consumed in pinned form, and rejections assert the typed reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * docs(adr-0014): promote ref-frame vocabulary and mark implementation status Flip ADR 0014 to Accepted, promote the ref-frame / frame-expiry-seam / mutation-admission vocabulary into CONTEXT.md, correct the `@ref` resolution note to the frame-tree model, record the migration status (steps 1–7 landed; coarse-marker removal follows live-evidence confirmation), update ADR 0012's divergence-ref amendment to accepted, and add a CHANGELOG entry for the fail-closed ref lifetime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * test(daemon): lock ADR 0014 evidence #1 and refresh module docs Add a daemon-level sequence test proving the canonical contract: after an unobserved first ref mutation, a second mutation rejects both bare and pinned with ref_frame_expired, and a fresh snapshot re-authorizes. Refresh the ref-frame module header and seam-expiry test comment now that enforcement is live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix(daemon): address ADR 0014 exact-head review — six lifetime blockers 1. Android dialog recovery aborts an outstanding ref action: a ref press/fill admitted against the pre-recovery frame now fails with ref_frame_expired when before-command recovery mutates the UI, instead of continuing against the recovered screen (selector/coordinate actions still re-resolve and continue). 2. open --relaunch expires the existing session's frame BEFORE the close dispatch, so a close timeout/failure that already tore the app down still leaves the old frame expired. 3. expireRefFrame clears scoped-snapshot lineage (snapshotScopeSource) at the seam, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can no longer borrow stale lineage across a device side effect. 4. Missing authorized-frame evidence fails closed: resolveSnapshotForRef no longer recaptures and accepts the same ref body from a newer tree by positional coincidence. A mutating find's internal dispatch resolves against its own fresh capture (omitRefFrameSnapshot), not the frame. 5. Mutating find omits refsGeneration — its acted ref is diagnostic pre-action identity and must not be pinnable after the action. 6. An empty partial publication leaves all session state untouched (including the coarse marker), instead of clearing it before finding there were no refs to issue. Adds focused regressions (lineage-cleared sequence, empty-partial no-op, fail-closed on unusable bounds, in-frame label recovery, mutating-find non-issuance) and extracts the find action dispatch to keep complexity in budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix: preserve snapshot refsGeneration + shared recovery rejection (ADR 0014 re-review) P1: structured JSON/Node snapshot results now retain the response-level refsGeneration. It was declared on the daemon response but dropped by the public CaptureSnapshotResult type, the serializer, and the Node normalizer, so default `snapshot -i --json` emitted refs with no generation to pin against. Added to the type, serializer, normalizer, plus CLI/Node tests. P2: Android dialog-recovery abort now reuses the SHARED admission rejection (refMutationAdmissionResponse) instead of a bespoke error, so the failure carries the full typed context (reason, ref, currentGeneration, scope, mintedGeneration) identical to every other expired-frame rejection across platforms. Removes the now-unused AppError/refFrameState imports. Adds a regression proving recovery aborts the outstanding ref action before any press dispatch. Also adds the relaunch failure-boundary regression (existing-session close fails after dispatch → old frame stays expired), and corrects the ADR implementation-status note so Android blocking-dialog recovery and a real provider-backed interaction/lifecycle are recorded as unexercised release blockers rather than confirmed enablement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * docs(adr-0014): record provider seam as live-verified; Android recovery sole blocker The provider-backed interaction + lifecycle seam is now confirmed by fresh live evidence (AWS Device Farm, webdriver backend). Update the ADR implementation-status note so only Android blocking-dialog recovery remains an unexercised release blocker — and note it is blocked on a bootable free Android target plus a deterministic app-owned ANR trigger, not on any code gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * docs(adr-0014): record Android ANR recovery as an accepted evidence gap Per the review decision: the Android blocking-dialog recovery seam has no deterministic app-owned ANR repro in the harness, so it was not live- exercised. The team accepted shipping without a live run for it — its transition/abort logic is covered by fixture regressions and it is enforced in code identically to the verified paths. Reword the status note from an open release blocker to a documented, accepted evidence gap, which unblocks step 8's coarse-marker removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0436793 commit 54977f3

59 files changed

Lines changed: 1906 additions & 365 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Breaking (ADR 0014, session ref-frame lifetime): a mutation through an `@ref` now expires the session's ref frame, so a later ref mutation without a fresh observation fails closed with a typed `details.reason` (`ref_frame_expired`, `ref_generation_mismatch`, `plain_ref_requires_complete_frame`, or `ref_not_issued`) instead of acting on a possibly-navigated screen. A ref-oriented sequence that performs several mutations must re-`snapshot` between them, consume an honestly issued `--settle` ref in pinned `@eN~s<gen>` form, or use selectors. Enforcement applies on every platform, not just iOS. Legacy hand-written `.ad` scripts that reuse several bare refs from one snapshot must capture between mutations or use selectors.
6+
- Ref reads resolve against the authorized ref frame's source tree, so an internal read-only capture (including Android freshness) can no longer retarget an admitted `@ref` by positional coincidence. Read-only ref consumers keep the structured staleness warning while the frame retains the ref's evidence.
7+
38
## 0.15.0
49

510
- Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps.

CONTEXT.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
- Coverage manifest: `CONTRACT_COVERAGE` export 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.
6262
- 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.
6363
- 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.
64+
- Ref frame (ADR 0014): the session's single authorization namespace for mutation `@ref`s, kept separate from the latest operational observation (`session.snapshot`). It owns a frozen epoch (the `refsGeneration` the client received), an immutable source tree, a lifecycle state (`active`/`expired`), and an issuance scope (`all` for a complete snapshot, or the bounded set of ref bodies a partial publication emitted). Owned solely by `src/daemon/ref-frame.ts`. A complete snapshot activates an `all` frame; `find`/settled diff/replay divergence activate a bounded partial frame that supersedes the prior one; internal read captures never activate or reindex it.
65+
- Frame expiry seam (ADR 0014): every mutating leaf calls `expireRefFrame` synchronously, 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 `@eN` against 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.
66+
- 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 reasons `ref_frame_expired``ref_generation_mismatch``plain_ref_requires_complete_frame``ref_not_issued`). Rejections carry `details.reason` and 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.
6467
- Settled observation: opt-in (`--settle`) post-action payload on press/click/fill/longpress — 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, `refsGeneration` when the settled tree was stored). Best-effort: never fails the action; `settled: false` plus a hint on never-quiet content.
6568
- 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.
6669
- 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.
@@ -136,8 +139,9 @@ the observable freshness and failure semantics below before any runtime refactor
136139
direct miss may fall back to the snapshot selector path, but ambiguous matches and runner errors
137140
must surface instead of silently falling back. `get text` uses direct native selectors only for
138141
simple `id` selectors because label/text/value reads need snapshot disambiguation.
139-
- Regular selector reads remain capture-backed. `@ref` reads resolve against stored session
140-
snapshots; selector `get`/`is`/`find`/`wait` capture through the backend. `find` and `wait`
142+
- Regular selector reads remain capture-backed. `@ref`s resolve against the authorized ref frame's
143+
source tree (ADR 0014), not whatever now sits at that index in a newer observation; selector
144+
`get`/`is`/`find`/`wait` capture through the backend. `find` and `wait`
141145
polling must bypass the 750 ms snapshot cache. The cache is also bypassed while Android freshness
142146
recovery or post-gesture stabilization is active.
143147
- Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace

docs/adr/0012-interactive-replay.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,11 +463,12 @@ every `screen` ref with `refsGeneration` before returning it, including on the e
463463
client callers receive the unpinned refs and generation already present in the daemon error. No caller
464464
gets a text-only divergence that loses its repair data.
465465

466-
> **Proposed ADR 0014 amendment:** if ADR 0014 is accepted and implemented, replay divergence
467-
> `screen.refs` becomes a partial ref publication. MCP keeps auto-pinning those refs; CLI text renders
468-
> `@eN~s<refsGeneration>`; JSON and Node.js callers pair each plain ref with the response-level
469-
> generation before mutation. Until that migration lands, the current unpinned contract above remains
470-
> accepted. The implementation must update this note and its contract tests in the same change.
466+
> **ADR 0014 amendment (accepted, implemented):** replay divergence `screen.refs` is now a partial ref
467+
> publication — it activates a bounded partial ref frame authorizing exactly the divergence screen's refs
468+
> (`markSessionPartialRefsIssued`). MCP auto-pins those refs; CLI text renders `@eN~s<refsGeneration>`;
469+
> JSON and Node.js callers pair each plain ref with the response-level generation before mutation. Because
470+
> the frame is partial, a mutation through a divergence ref requires the pinned form; a plain ref there
471+
> reports `plain_ref_requires_complete_frame`.
471472
472473
`--from N` is a `replay`-only flag. `test` must reject it as `INVALID_ARGS`; test shares replay execution
473474
but must remain a full, deterministic suite run. `N` is a 1-based index into the fully expanded

docs/adr/0014-session-ref-frame-lifetime.md

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
Proposed
5+
Accepted
66

77
## Context
88

@@ -35,10 +35,9 @@ remain compatible with selector-based replay, and add no automatic capture or pe
3535

3636
## Decision
3737

38-
The terms introduced below describe the proposed target model. They do not replace the current domain
39-
language in `CONTEXT.md` while this ADR is Proposed and the source still implements the snapshot/stale
40-
marker model. The implementation change that establishes these concepts must promote the accepted
41-
terms into `CONTEXT.md`; documentation must not present the target model as current behavior early.
38+
The terms introduced below are now the implemented model; the ref-frame vocabulary is promoted into
39+
`CONTEXT.md`. The coarse `snapshotRefsStale` marker still backs read-only staleness warnings and is
40+
removed once per-platform enforcement is confirmed by live evidence (migration step 8).
4241

4342
### Ref frames are separate from operational observations
4443

@@ -69,15 +68,15 @@ compatibility.
6968

7069
### Frame transitions
7170

72-
| Event | Frame transition | Plain refs | Pinned refs |
73-
| --- | --- | --- | --- |
74-
| Internal observation that returns no refs | None | Unchanged | Unchanged |
75-
| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches |
76-
| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch |
77-
| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected |
78-
| Additional effects while already expired | Idempotent | Rejected | Rejected |
79-
| Sparse, failed, or unusable capture | None | Unchanged | Unchanged |
80-
| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today |
71+
| Event | Frame transition | Plain refs | Pinned refs |
72+
| ----------------------------------------- | -------------------------------------- | -------------------------- | ------------------------------------------------ |
73+
| Internal observation that returns no refs | None | Unchanged | Unchanged |
74+
| Complete frame activation | New active epoch, scope `all` | Accepted from this frame | Accepted when epoch matches |
75+
| Non-empty partial publication | New active epoch, scope = emitted refs | Rejected | Accepted only for an emitted ref at this epoch |
76+
| First possible device-side effect | Advance once to `expired` | Rejected | Previous epoch rejected |
77+
| Additional effects while already expired | Idempotent | Rejected | Rejected |
78+
| Sparse, failed, or unusable capture | None | Unchanged | Unchanged |
79+
| Session reopen | New random-seeded lifetime | Rejected until publication | Old lifetime rejected probabilistically as today |
8180

8281
A complete activation normally accompanies a command result that exposes the complete ref namespace
8382
for the stored frame, including an interactive or intentionally scoped snapshot. An intentionally
@@ -210,9 +209,7 @@ The command descriptor's daemon facet declares a request policy:
210209
```ts
211210
type RefFrameEffect = 'preserve' | 'may-invalidate' | 'delegated';
212211

213-
type DaemonRefFrameEffect =
214-
| RefFrameEffect
215-
| ((request: DaemonRequest) => RefFrameEffect);
212+
type DaemonRefFrameEffect = RefFrameEffect | ((request: DaemonRequest) => RefFrameEffect);
216213
```
217214

218215
The daemon registry exposes a named resolver. Every daemon command is classified, but the
@@ -440,6 +437,27 @@ Each step lands green and independently useful:
440437
evidence; promote the implemented vocabulary into `CONTEXT.md`; then remove the superseded coarse
441438
stale marker.
442439

440+
Implementation status: steps 1–7 have landed — the ref-frame module and observation split (1), the
441+
complete daemon classification and gate (2), the pre-side-effect seam at every leaf (3), correct
442+
complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4),
443+
Android freshness decoupled from positional ref authorization (5), the cross-platform contract and
444+
provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7).
445+
Fresh live evidence has exercised nearly every production seam — Apple runtime-ref, direct/native
446+
selector, generation-pin, generic, and lifecycle paths; Android helper freshness (including proven
447+
non-retarget) and Android existing-session relaunch; and a real provider-backed interaction plus
448+
provider-backed lifecycle operation (AWS Device Farm, `backend: webdriver`: a fresh ref succeeded, an
449+
immediate stale ref was rejected before dispatch with the shared typed fields, an `open --relaunch`
450+
lifecycle mutation expired the frame, and a fresh observation restored authorization). ONE seam —
451+
Android blocking-dialog recovery — was NOT live-exercised: the repo harness exposes no deterministic
452+
app-owned ANR trigger, and no reproducible control exists to raise the system dialog on demand. The
453+
team accepted shipping without a live run for it: its transition and abort logic are covered by fixture
454+
regressions (`android-system-dialog-ref-frame.test.ts` proves recovery expires the frame before its
455+
tap; `interaction-android-recovery-abort.test.ts` proves an outstanding ref action then aborts with the
456+
shared `ref_frame_expired` rejection and no dispatch), the seam is enforced in code identically to the
457+
verified paths, and it is recorded here as a documented, accepted evidence gap rather than an open
458+
release blocker. Every other enabled seam is proven on hardware, so step 8's removal of the superseded
459+
coarse `snapshotRefsStale` marker is unblocked.
460+
443461
PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker
444462
case before this full lifecycle is implemented; it does not own the architecture migration.
445463

src/__tests__/client.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,24 @@ test('client capture.snapshot preserves visibility metadata from daemon response
903903
});
904904
});
905905

906+
test('client capture.snapshot preserves refsGeneration from daemon responses (ADR 0014)', async () => {
907+
const setup = createTransport(async () => ({
908+
ok: true,
909+
data: {
910+
nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Button', label: 'Go' }],
911+
truncated: false,
912+
refsGeneration: 752890,
913+
},
914+
}));
915+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
916+
917+
const result = await client.capture.snapshot();
918+
919+
// Node.js callers must retain the response-level generation to pin a plain ref
920+
// (`@e1~s752890`) before a mutation.
921+
assert.equal(result.refsGeneration, 752890);
922+
});
923+
906924
test('client capture.snapshot preserves snapshot quality annotation from daemon responses', async () => {
907925
const snapshotQuality = {
908926
state: 'recovered',

src/agent-device-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ function optionalSnapshotResponseFields(
501501
| 'warnings'
502502
| 'snapshotQuality'
503503
| 'snapshotDiagnostics'
504+
| 'refsGeneration'
504505
>
505506
> {
506507
const visibility = readObject(data.visibility);
@@ -511,6 +512,9 @@ function optionalSnapshotResponseFields(
511512
...readSerializedSnapshotCaptureAnnotations(data),
512513
...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}),
513514
...(snapshotDiagnostics ? { snapshotDiagnostics } : {}),
515+
// ADR 0014: keep the response-level ref-frame generation on Node.js results
516+
// so callers can pin refs (`@e12~s<refsGeneration>`) before a mutation.
517+
...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}),
514518
};
515519
}
516520

src/client/client-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,12 @@ export type CaptureSnapshotResult = {
450450
unchanged?: SnapshotUnchanged;
451451
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
452452
identifiers: AgentDeviceIdentifiers;
453+
/**
454+
* ADR 0014: the response-level ref-frame epoch the plain node refs were minted
455+
* from. A ref-issuing snapshot carries it ONCE (nodes stay plain `@e12` for the
456+
* token budget); pair a ref with it (`@e12~s<refsGeneration>`) before a mutation.
457+
*/
458+
refsGeneration?: number;
453459
} & PublicSnapshotCaptureAnnotations;
454460

455461
export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & {

src/commands/interaction/output.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@ function getCliOutput(params: { result: CommandRequestResult; format?: string })
1414
return defaultCommandCliOutput(data);
1515
}
1616

17+
// ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy
18+
// `@eN~s<refsGeneration>` form so a human CLI caller can paste it into the next
19+
// mutation without a separate pin step. A mutating result carries no
20+
// `refsGeneration`, so its acted ref is never pinned.
21+
function pinnedRefText(ref: unknown, refsGeneration: unknown): string | undefined {
22+
if (typeof ref !== 'string' || ref.length === 0) return undefined;
23+
if (typeof refsGeneration !== 'number') return undefined;
24+
const body = ref.startsWith('@') ? ref.slice(1) : ref;
25+
return `@${body}~s${refsGeneration}`;
26+
}
27+
1728
function findCliOutput(result: CommandRequestResult): CliOutput {
1829
const data = result as Record<string, unknown>;
1930
// Interactive find actions (click/fill/focus/type) carry the same success message as
2031
// their direct counterparts; prefer it over the raw text field fill responses include.
2132
const message = readCommandMessage(data);
2233
if (message) return { data, text: message };
2334
if (typeof data.text === 'string') return { data, text: data.text };
35+
// A read-only find that returns a reusable ref renders it pinned (ADR 0014).
36+
const pinned = pinnedRefText(data.ref, data.refsGeneration);
37+
if (pinned) return { data, text: `Found: ${pinned}` };
2438
if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` };
2539
if (data.node) return { data, text: JSON.stringify(data.node, null, 2) };
2640
return defaultCommandCliOutput(data);
@@ -64,6 +78,7 @@ type SettleTextView = {
6478
};
6579
tail?: Array<{ ref?: string; role?: string; label?: string }>;
6680
tailTruncated?: boolean;
81+
refsGeneration?: number;
6782
};
6883

6984
/**
@@ -100,7 +115,10 @@ function formatSettleTailLines(view: SettleTextView): string[] {
100115
const lines = [`unchanged interactive (${tail.length}):`];
101116
for (const entry of tail) {
102117
const label = entry.label ? ` "${entry.label}"` : '';
103-
lines.push(`= @${entry.ref ?? ''} [${entry.role ?? ''}]${label}`);
118+
// ADR 0014: the settled tail refs are reusable, so render them pinned when
119+
// the settle response carried its generation.
120+
const ref = pinnedRefText(entry.ref, view.refsGeneration) ?? `@${entry.ref ?? ''}`;
121+
lines.push(`= ${ref} [${entry.role ?? ''}]${label}`);
104122
}
105123
if (view.tailTruncated) {
106124
lines.push('… more interactive elements not shown, use snapshot -i');

src/commands/interaction/runtime/resolution.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ test('runtime interactions reject unsupported macOS desktop and menubar surfaces
412412
assert.equal(pressed, true);
413413
});
414414

415-
test('runtime ref interactions refresh the snapshot when a stored ref has no usable rect', async () => {
415+
test('runtime ref interactions fail closed when the authorized ref has no usable bounds (ADR 0014)', async () => {
416416
const staleSnapshot = makeSnapshotState([
417417
{
418418
index: 0,
@@ -422,25 +422,30 @@ test('runtime ref interactions refresh the snapshot when a stored ref has no usa
422422
hittable: true,
423423
},
424424
]);
425-
const freshSnapshot = selectorSnapshot();
426425
const calls: Point[] = [];
427426
let captures = 0;
428427
const device = createInteractionDevice(staleSnapshot, {
429428
captureSnapshot: async () => {
430429
captures += 1;
431-
return { snapshot: freshSnapshot };
430+
return { snapshot: selectorSnapshot() };
432431
},
433432
tap: async (_context, point) => {
434433
calls.push(point);
435434
},
436435
});
437436

438-
const result = await device.interactions.click(ref('@e1'), { session: 'default' });
439-
440-
assert.equal(captures, 1);
441-
assert.deepEqual(calls, [{ x: 60, y: 40 }]);
442-
assert.equal(result.kind, 'ref');
443-
assert.equal(result.node?.rect?.width, 100);
437+
// ADR 0014: the authorized frame's @e1 has no usable rect, so it FAILS rather
438+
// than recapturing and accepting the same index from a newer tree by
439+
// positional coincidence.
440+
await assert.rejects(
441+
() => device.interactions.click(ref('@e1'), { session: 'default' }),
442+
(error: unknown) => {
443+
assert.match((error as Error).message, /Ref @e1 not found or has no bounds/);
444+
return true;
445+
},
446+
);
447+
assert.equal(captures, 0);
448+
assert.deepEqual(calls, []);
444449
});
445450

446451
test('tryResolveRefNode discloses exact for a resolved ref and label-fallback for label recovery', () => {

0 commit comments

Comments
 (0)