Commit 54977f3
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
File tree
- docs/adr
- src
- __tests__
- client
- commands/interaction
- runtime
- contracts
- core/command-descriptor
- __tests__
- daemon
- __tests__
- handlers
- __tests__
- mcp
- test
- integration/provider-scenarios
- output-economy
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
3 | 8 | | |
4 | 9 | | |
5 | 10 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
64 | 67 | | |
65 | 68 | | |
66 | 69 | | |
| |||
136 | 139 | | |
137 | 140 | | |
138 | 141 | | |
139 | | - | |
140 | | - | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
141 | 145 | | |
142 | 146 | | |
143 | 147 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
463 | 463 | | |
464 | 464 | | |
465 | 465 | | |
466 | | - | |
467 | | - | |
468 | | - | |
469 | | - | |
470 | | - | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
471 | 472 | | |
472 | 473 | | |
473 | 474 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
42 | 41 | | |
43 | 42 | | |
44 | 43 | | |
| |||
69 | 68 | | |
70 | 69 | | |
71 | 70 | | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
81 | 80 | | |
82 | 81 | | |
83 | 82 | | |
| |||
210 | 209 | | |
211 | 210 | | |
212 | 211 | | |
213 | | - | |
214 | | - | |
215 | | - | |
| 212 | + | |
216 | 213 | | |
217 | 214 | | |
218 | 215 | | |
| |||
440 | 437 | | |
441 | 438 | | |
442 | 439 | | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
443 | 461 | | |
444 | 462 | | |
445 | 463 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
903 | 903 | | |
904 | 904 | | |
905 | 905 | | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
906 | 924 | | |
907 | 925 | | |
908 | 926 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
501 | 501 | | |
502 | 502 | | |
503 | 503 | | |
| 504 | + | |
504 | 505 | | |
505 | 506 | | |
506 | 507 | | |
| |||
511 | 512 | | |
512 | 513 | | |
513 | 514 | | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
514 | 518 | | |
515 | 519 | | |
516 | 520 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
450 | 450 | | |
451 | 451 | | |
452 | 452 | | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
453 | 459 | | |
454 | 460 | | |
455 | 461 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
17 | 28 | | |
18 | 29 | | |
19 | 30 | | |
20 | 31 | | |
21 | 32 | | |
22 | 33 | | |
23 | 34 | | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
24 | 38 | | |
25 | 39 | | |
26 | 40 | | |
| |||
64 | 78 | | |
65 | 79 | | |
66 | 80 | | |
| 81 | + | |
67 | 82 | | |
68 | 83 | | |
69 | 84 | | |
| |||
100 | 115 | | |
101 | 116 | | |
102 | 117 | | |
103 | | - | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
104 | 122 | | |
105 | 123 | | |
106 | 124 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
412 | 412 | | |
413 | 413 | | |
414 | 414 | | |
415 | | - | |
| 415 | + | |
416 | 416 | | |
417 | 417 | | |
418 | 418 | | |
| |||
422 | 422 | | |
423 | 423 | | |
424 | 424 | | |
425 | | - | |
426 | 425 | | |
427 | 426 | | |
428 | 427 | | |
429 | 428 | | |
430 | 429 | | |
431 | | - | |
| 430 | + | |
432 | 431 | | |
433 | 432 | | |
434 | 433 | | |
435 | 434 | | |
436 | 435 | | |
437 | 436 | | |
438 | | - | |
439 | | - | |
440 | | - | |
441 | | - | |
442 | | - | |
443 | | - | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
444 | 449 | | |
445 | 450 | | |
446 | 451 | | |
| |||
0 commit comments