feat(daemon): ADR 0014 session ref-frame lifetime — full implementation#1257
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
CI blocks review on |
|
Resolved on the current head ( The step-3 commit wires it into production: the generic daemon leaf calls
Generated by Claude Code |
|
Exact-head review of
The production export issue is fixed and CI is green, but this head is not ready as ADR 0014 steps 1-3 complete. |
|
Thanks — all three addressed in 1. Partial issuance no longer restores complete authority. You're right: every caller of 2. Keyboard resolver covers every mutating subaction. Fixed — 3. Remaining step-3 seams wired, with tests. Confirmed the gaps and closed them:
Focused seam tests prove the frame expires for each. The PR body's step-3 section is updated to list the full seam set. Enforcement of the expired-frame rejection stays deferred to step 7 behind the per-platform live-evidence gate, so behavior is unchanged. CI re-running on Generated by Claude Code |
|
Verified PR head Live verification
Build / cleanup
No blocking review findings for the stated steps 1–3 scope. Direct-iOS selector, lifecycle, Android recovery/provider seams, partial issuance, and fail-closed enforcement remain intentionally deferred as described in the PR/ADR. |
|
Thanks for the live verification 🙏 One heads-up on head: you verified
On the deferral note: I took option A from your other review comment and completed the direct-iOS selector, mutating- Generated by Claude Code |
|
Re-review of One step-3 blocker remains: classified specialized mutating routes still do not cross Add the transition at each specialized leaf and focused seam coverage, or narrow/resequence the PR so it does not claim migration step 3 is complete. CI is green and the prior live evidence remains useful, but this head is not ready under the stated steps 1-3 scope. |
Live device verification request (ADR 0014 step 7 enforcement gate)Fail-closed ref-mutation enforcement is now implemented across platforms (commit Could the local agent run the following against real targets on a fresh build? For each seam, the three-part proof is the same:
Seams to cover (skip cleanly any that aren't available on the hardware — just note which):
For each run, the daemon request log ( Thanks! I'll continue with the remaining scope (contract evidence, docs/CONTEXT.md promotion, coarse-marker removal) in the meantime. Generated by Claude Code |
|
Exact-head review of
Fresh live evidence is also still a release gate: this head enables fail-closed behavior across platforms while the PR body explicitly says the required production seams remain unverified. Fixture/provider tests do not replace that evidence under ADR 0014. |
|
Fresh live verification completed against exact PR head Build freshness
Apple — iPhone 16 simulator, iOS 26.2
Key rejection logs:
Android — Pixel 9 Pro XL API 37 emulator
Key rejection logs:
Cleanup
This comment records only the production seams exercised in this run. |
|
Re-review of exact head
The scoped-lineage, missing-frame, mutating-find, and empty-partial fixes look correct. All current checks are green, but this is not merge-ready yet. |
|
Reconciled the re-review + verdict findings on head Recovery rejection now uses the shared admission shape (blocker 1 / verdict P2). Relaunch failure-boundary regression added (blocker 2). New test: existing-session Enablement claim corrected (blocker 3). The ADR implementation-status note now records Android blocking-dialog recovery and a real provider-backed interaction + lifecycle op as unexercised release blockers, not confirmed enablement — enforcement stays on in code, but those two seams are explicitly not claimed verified until their live runs exist. Everything else is backed by your fresh live evidence. So those two runs remain the outstanding gate (my request in 4969256180 still stands for exactly those two). Snapshot The scoped-lineage, missing-frame, mutating-find, and empty-partial fixes you confirmed are unchanged. Full The two live seams (Android recovery, real provider) are the only thing between this and merge-ready — happy to have the local agent run just those two when convenient. Generated by Claude Code |
|
Fresh live verification against current head
Focused regressions on this head also pass: result serialization, Node client normalization, Android recovery abort/no-dispatch, and relaunch-close failure boundary (58 tests across 4 files). Cleanup verified: the isolated session list is empty and |
Fresh live evidence — exact head eb2ea11Provider seam: PASSTarget: AWS Device Farm, Google Pixel 9 Pro XL (Android 17), provider session Public commands from the exact-head worktree: Observed:
Android blocking-dialog recovery: BLOCKEDFresh build preparation passed: The only clearly free powered-off local target Safe handoff needs a bootable free Android target plus a deterministic app-owned ANR control/input. The next public flow is: Cleanup: provider disconnected and completed; PR-specific local session lists are empty; |
…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
… 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
… 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
…am 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
…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
… 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
…4 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
…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
…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
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
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
…R 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
…ry 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
45da8d5 to
c769eed
Compare
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
|
…ef frame Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on main and changed captureDivergenceObservation to activate a PARTIAL ref frame (markSessionPartialRefsIssued) authorizing exactly the divergence screen's emitted refs — computing that "digestBodies" set with its own document-order, non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the authorized frame would no longer match the shown screen: in the mass-covered fallback the screen surfaces covered refs that #1257's non-covered-only frame filter excluded, leaving the agent a ref the screen advertised but the frame rejects. Extract selectDivergenceScreenRefNodes as the single source of truth for which nodes screen.refs publishes and in what order. Both the rendered digest (buildReplayDivergenceScreenRefs) and the partial-frame authorization (captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it, so the frame authorizes exactly the emitted set — preserving BOTH #1257's ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the captureDivergenceObservation doc to the partial-frame sequence. Test: assert the partial ref frame scope (session.refFrameScope) equals the emitted screen.refs set in the mass-covered fallback (covered refs included) — verified to fail on #1257's original non-covered-only digestBodies. Refs #1264 #1257
#1265) * fix: unify divergence screen capture with snapshot's full-window scope Route captureDivergenceObservation through captureSnapshotData — the same function the snapshot command itself builds its capture with (Android's snapshot-helper full-window route with its graceful app-scoped fallback, iOS's bounded system-modal probe path, macOS/Linux surface-scoped branches) — instead of a parallel hand-rolled dispatchCommand call. The chrome filter and meaningful-target filter stay layered on top as filters over that full capture, never as a scoping. Amends ADR-0012 decision 4 to state the invariant: an agent must never see a healthier `screen` in a divergence report than a plain `snapshot` would show it, so a separate-window system overlay (volume dialog, quick-settings shade, permission dialog) must survive into `screen.refs` exactly as `snapshot` would present it. Also fixes the synthetic `volume_dialog_slider` id in snapshot-chrome-android-statusbar.test.ts to the real, live-verified `volume_new_ringer_active_icon_container` id and rewords the test comment to read as a filter-logic unit test rather than a live-capture-path claim, and adds unit coverage for the invariant itself. Fixes #1264 * fix: rank divergence screen.refs within the cap so overlays are not buried The #1264 root cause is cap burial, not capture scope: buildReplayDivergenceScreenRefs sliced candidates in document order, so a fully-captured separate-window overlay (volume dialog, QS shade, permission dialog) that enumerates after the app window's ~77 nodes lands past position 20 and is truncated away — the report shows a healthy-looking app under a covering overlay it cannot see (archived evidence: screen.truncated: true, zero volume refs). - Rank within the cap instead of document-order slicing: foreign-window (non-app-bundleId) hittable nodes — the dismiss targets for whatever covers the app — are promoted ahead of app content, otherwise stable (document order preserved within each tier; equal-priority app nodes never reshuffled). The 20-cap is a byte bound, not a first-20-in-tree-order policy. - Occlusion fallback: when a system overlay mass-covers the app (every app node annotated interactionBlocked: 'covered'), surface those covered nodes rather than emitting an empty screen.refs — a report whose capture holds meaningful nodes but whose refs is empty is broken by construction. - repairHint/suggestions consume the full captured node list, not the capped refs slice, so hint routing is unaffected; only screen.refs selection changes. Detection keys off node.bundleId (Android-only, from the a11y package); iOS/macOS leave per-node bundleId undefined, so ranking degrades to document order there (safe — those platforms surface modals via the probe path, not by cap-competing). Guarded on a known appBundleId so a sessionless capture never reorders. Tests: replaces the small-fixture #1264 test (which the overlay fit inside the cap regardless of order, so it did not prove the invariant) with a realistic full fixture (24 app controls + overlay dismiss-target captured LAST) that fails on document-order slicing and passes with ranking; plus occlusion tests (mass-covered app -> overlay surfaces, refs non-empty; bare-scrim fallback -> covered app nodes surfaced, refs non-empty). Both were verified to fail before the fix. ADR-0012 decision 4 amendment reworded to cover ref-selection ranking and the occlusion guarantee, not only capture scope. Refs #1264 * fix: route divergence capture through captureSnapshot wrapper + clean flags policy Completes the #1264 capture unification. The prior round routed captureDivergenceObservation through captureSnapshotData (the inner single-shot capture), but plain `snapshot`'s backend calls the HIGHER captureSnapshot wrapper, which owns Android freshness + post-action retry. A divergence could therefore consume the first stale/app-scoped dump while a plain `snapshot` retries to the fresh full-window tree — a divergence staler/narrower than `snapshot`, violating the invariant. - Route the divergence capture through the same `captureSnapshot` wrapper as plain snapshot, so it inherits freshness/post-action retry parity. No fork: the wrapper's params (device, session, flags, logPath) are all suppliable from the divergence path. - Build the divergence capture's flags from a clean, fixed policy (`divergenceCaptureFlags`: full-window, non-raw, default depth) instead of spreading the failed action's flags — so a failed `snapshot --raw`/scoped/`-d` action can no longer narrow the diagnostic tree. Only the interactive-only policy is carried (extracted as a helper so captureDivergenceObservation stays within complexity budget). Tests: a freshness-retry regression (session carries an active Android freshness marker; capture-1 is a stale near-empty dump that trips sharp-drop, capture-2 holds the overlay — asserts the divergence uses the retried fresh tree and dispatched twice), and a clean-flags regression (a failed raw/scoped/depth action — asserts the snapshot dispatch context drops snapshotRaw/scope/depth while still applying interactive-only). Both verified to fail on the pre-fix code. ADR-0012 decision 4 amendment updated to state the same-wrapper (freshness parity) and clean-flags guarantees. Live overlay acceptance remains a maintainer device step (env down): unit fixtures prove ref SELECTION after nodes are supplied, not that the Android helper returns the separate-window overlay at divergence time. Refs #1264 * test: stub the freshness-retry sleep so the capture-parity test doesn't real-wait The #1264 capture-parity regression exercised the real Android sharp-drop retry, which awaited the real ~250 ms `sleep` delay — repo guidance forbids real-time waits in unit tests. Mock `sleep` (the delay the retry path in snapshot-capture.ts awaits) to a no-op at the module level, so the retry BRANCH still executes (loop runs, retries, re-captures) without a wall-clock wait. The test still proves the branch: two on-device dispatches and use of the retried fresh tree (overlay present). Verified it still fails on the pre-fix single-shot path (1 dispatch) with the delay stubbed, so the stub does not make it vacuous. No production change; the delay stub needs no DI seam since `sleep` is a plain module export. Refs #1264 * fix: reconcile divergence ref selection with #1257 ADR-0014 partial ref frame Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on main and changed captureDivergenceObservation to activate a PARTIAL ref frame (markSessionPartialRefsIssued) authorizing exactly the divergence screen's emitted refs — computing that "digestBodies" set with its own document-order, non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the authorized frame would no longer match the shown screen: in the mass-covered fallback the screen surfaces covered refs that #1257's non-covered-only frame filter excluded, leaving the agent a ref the screen advertised but the frame rejects. Extract selectDivergenceScreenRefNodes as the single source of truth for which nodes screen.refs publishes and in what order. Both the rendered digest (buildReplayDivergenceScreenRefs) and the partial-frame authorization (captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it, so the frame authorizes exactly the emitted set — preserving BOTH #1257's ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the captureDivergenceObservation doc to the partial-frame sequence. Test: assert the partial ref frame scope (session.refFrameScope) equals the emitted screen.refs set in the mass-covered fallback (covered refs included) — verified to fail on #1257's original non-covered-only digestBodies. Refs #1264 #1257
…er (ADR 0014 step 8) The coarse `snapshotRefsStale` client-stale marker is fully superseded by the ref-frame model and is removed: - `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it — replacing the latest observation is a read that never touches the frame. - Read-only ref staleness now derives from frame state: a plain ref warns once the frame has EXPIRED (a device side effect changed the screen), and a read-only capture no longer marks refs stale because it does not expire the frame. Pinned-ref warnings keep comparing against the frozen frame epoch. - Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker) and the `session.snapshotRefsStale` field. Migrates every test off the marker to the frame model (frame-expiry drives the read warning; complete/partial activation drives admission), and updates the ADR status + module docs to record step 8 as landed. Ships as follow-up to the merged #1257 since that PR closed before this step. Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…er (ADR 0014 step 8) The coarse `snapshotRefsStale` client-stale marker is fully superseded by the ref-frame model and is removed: - `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it — replacing the latest observation is a read that never touches the frame. - Read-only ref staleness now derives from frame state: a plain ref warns once the frame has EXPIRED (a device side effect changed the screen), and a read-only capture no longer marks refs stale because it does not expire the frame. Pinned-ref warnings keep comparing against the frozen frame epoch. - Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker) and the `session.snapshotRefsStale` field. Migrates every test off the marker to the frame model (frame-expiry drives the read warning; complete/partial activation drives admission), and updates the ADR status + module docs to record step 8 as landed. Ships as follow-up to the merged #1257 since that PR closed before this step. Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…er (ADR 0014 step 8) The coarse `snapshotRefsStale` client-stale marker is fully superseded by the ref-frame model and is removed: - `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it — replacing the latest observation is a read that never touches the frame. - Read-only ref staleness now derives from frame state: a plain ref warns once the frame has EXPIRED (a device side effect changed the screen), and a read-only capture no longer marks refs stale because it does not expire the frame. Pinned-ref warnings keep comparing against the frozen frame epoch. - Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker) and the `session.snapshotRefsStale` field. Migrates every test off the marker to the frame model (frame-expiry drives the read warning; complete/partial activation drives admission), and updates the ADR status + module docs to record step 8 as landed. Ships as follow-up to the merged #1257 since that PR closed before this step. Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa
…er (ADR 0014 step 8) (#1268) * refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8) The coarse `snapshotRefsStale` client-stale marker is fully superseded by the ref-frame model and is removed: - `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it — replacing the latest observation is a read that never touches the frame. - Read-only ref staleness now derives from frame state: a plain ref warns once the frame has EXPIRED (a device side effect changed the screen), and a read-only capture no longer marks refs stale because it does not expire the frame. Pinned-ref warnings keep comparing against the frozen frame epoch. - Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker) and the `session.snapshotRefsStale` field. Migrates every test off the marker to the frame model (frame-expiry drives the read warning; complete/partial activation drives admission), and updates the ADR status + module docs to record step 8 as landed. Ships as follow-up to the merged #1257 since that PR closed before this step. Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix(daemon): resolve @ref reads from the frame tree; scope find's internal warning Address three review blockers on the coarse-marker removal (ADR 0014 step 8): 1. @ref reads now bind against the authorized frame tree (`refFrameSnapshot ?? snapshot`) in `requireSnapshotSession`, so an internal read-only capture that replaced the observation cannot let a plain `@eN` resolve a different element by positional coincidence. Missing frame evidence fails instead of falling through to a newer observation. 2. A mutating find's internal leaf dispatch (`internal.findResolvedTarget`) no longer attaches a stale-ref warning in either the press or fill path — the caller never consumed a `@ref`, so the public find response must not claim it did. 3. `resolveRefStalenessWarning` checks frame expiry FIRST, matching the admission order: an expired frame is stale for any ref, even a pin that matches the epoch (a matching pin proves identity within the retained frame, not that the UI is current). Regressions: divergent observation-vs-frame trees resolve from the frame tree or fail when evidence is missing; a locator-based mutating find from an expired frame carries no stale-ref warning; the reordered resolver unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix: correct stale-ref warning comments and ADR-0014 present-tense marker refs The get/wait dispatch comments in selector-runtime.ts still described the superseded coarse snapshotRefsStale marker ("warn when that tree was replaced since the client last received refs") even though staleness is now derived from ref-frame expiry (ADR 0014 migration step 8). Reworded both to describe the frame-derived mechanism actually implemented by resolveRefStalenessWarning. session-snapshot.ts's early-return comment in markSessionPartialRefsIssued referenced "the coarse marker" as something still left untouched, but that field no longer exists — reworded to name the ref frame fields it actually preserves. ADR-0014's "Ref frames are separate from operational observations" section still described snapshotRefsStale as part of "the existing... implementation" in present tense, contradicting the Decision section's own note (line 39) that migration step 8 already removed it. Reworded to keep the historical mention while stating the removal. * fix: frame-lifetime wording for the stale-ref warning and read comments Address the follow-up review blocker plus the co-located terminology cleanup (ADR 0014 step 8): - STALE_SNAPSHOT_REFS_WARNING no longer claims "the session snapshot changed"; it now describes frame lifetime in terms valid for both read warnings and mutation rejection — the UI may have changed since the refs were issued, so take a new snapshot before relying on or interacting with them. The warning fires on frame expiry, including device side effects where no stored snapshot changed. - selector-runtime.ts: the get/wait @ref comments now say the read binds to the retained ref-frame evidence and its staleness is frame-derived, not a property of the stored snapshot or the live polling capture. - settle.ts: an unsettled stored capture replaces the observation without touching the ref frame; read staleness is driven by side-effect-seam expiry, not by storing a fresh observation. - interaction-settle.test.ts: renamed the settle test off the removed stale-marker language to "activates a partial ref frame" (what it asserts). Comments/test-name/warning-text only — no runtime behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa * fix(daemon): name the ref-frame epoch in the pinned-stale-ref warning The pinned-ref warning is compared against refFrameEpoch(session) — the frozen frame epoch — not the latest observation generation, and after a read-only capture those two diverge. The message still said "the session tree is now sN", which is ambiguous once the observation counter has advanced past the frame epoch. Name the ref-frame epoch instead: Ref @E12 was minted from snapshot s3 but the session's ref frame is now s15 — re-run snapshot -i. Renames the builder param to `currentFrameEpoch` and corrects its doc comment to say the pin is compared against the frame epoch, not the stored tree generation. Regression: `resolveRefStalenessWarning` names the frozen frame epoch, not the bumped observation generation — a read-only `setSessionSnapshot` advances the observation counter (15 -> 16) while the frame epoch stays frozen at 15; a pin at s15 is clean and a pin at s12 names s15, never s16. 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>
Summary
Full implementation of ADR 0014: Session Ref-Frame Lifetime (accepted in #1247). The PR grew from the original steps 1–3 to the complete ADR at the maintainer's request ("do all the scope of the ADR in this PR"). It separates the mutation-authorization namespace (the ref frame) from the latest operational observation, and enforces ref lifetime fail-closed on every platform.
What the model does
refsGeneration), an immutable source tree, a lifecycle state (active/expired), and an issuance scope (allor a bounded set). Owned solely bysrc/daemon/ref-frame.ts.@refs resolve against the frame's source tree, never against a newer observation — so an Android freshness (or any read-only) capture cannot retarget an admitted ref by positional coincidence; fresh coordinates are adopted only when the node's local identity still matches.details.reasonand name the lifetime failure.Migration steps landed
ref-frame.ts, order-sensitiveadmitRefMutationreasons (ref_frame_expired→ref_generation_mismatch→plain_ref_requires_complete_frame→ref_not_issued), behind the existingsnapshotGeneration/snapshotRefsStalefields; wire names unchanged.refFrameEffectclassification + completeness gate. All daemon-faceted commands classified; request-sensitive resolvers forkeyboard/alert;app-switcherfacet added.findfocus/type, and every specialized leaf — keyboard dismiss/enter/return, alert accept/dismiss, settings, React Native overlay dismiss, install/reinstall,open/relaunch,close, and Android blocking-dialog recovery (67272af, addressing the step-3 specialized-leaf review). Each with a focused seam test.find/settled/divergence publish a bounded ref set; scoped-snapshot lineage; MCP pin retention (mutatingfindnon-issuing); pinned partial CLI text with output-economy waivers; response-levelrefsGenerationunchanged for JSON/Node.refFrameGeneration) so a read-only capture can't falsely reject a valid pin; mutatingfindre-resolves by locator and bypasses admission while still crossing the seam.CONTEXT.md; ADR 0012 divergence-ref amendment marked implemented; CHANGELOG entry.The one deferred item
Per the ADR's own step-8 sequencing, removal of the superseded coarse
snapshotRefsStalemarker (still backing read-only warnings) follows per-platform enforcement confirmation by fresh live-device evidence — requested in this comment. A seam not exercised on hardware stays an explicit release blocker per the ADR; the marker removal is the clean final commit once those runs land.Validation
tscclean;oxlint,oxfmt,fallow audit,check:production-exportsclean; nothing added to the fallow baseline/allowlist.unit-core+provider-integration+output-economy+android-adbgreen (4084 tests).🤖 Generated with Claude Code