Skip to content

feat(replay): recorded landmark identity for wait, is coverage — read-only step identity (#1349)#1381

Merged
thymikee merged 9 commits into
mainfrom
claude/agent-device-1349-design-fd6ed3
Jul 24, 2026
Merged

feat(replay): recorded landmark identity for wait, is coverage — read-only step identity (#1349)#1381
thymikee merged 9 commits into
mainfrom
claude/agent-device-1349-design-fd6ed3

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes #1349.

What

Extends ADR 0012's target-v1 identity verification to the eligible read-only steps without changing their execution semantics, and strengthens ADR 0016's destination guard from selector existence to recorded landmark identity. Four commits, sequenced dependency-first:

  1. refactor(replay): shared tree helpers — moves the pure evidence primitives (buildIndexMap/buildAncestryChain/filterIdentitySet) from the daemon into src/replay/target-evidence-tree.ts so the commands-layer wait loop can consume them (commands → replay was already a legal DAG edge); deletes press-retarget.ts's private buildIndexMap duplicate.
  2. feat(replay): wait landmark verification + is coverage — the core.
  3. feat(publication): guard requires verified evidence + the reshuffled-screen false-pass regression.
  4. refactor(replay): dedupe/complexity cleanup shaken out by the fallow gates, plus ADR/help docs.

Design decisions a reviewer should check

  • Same annotation, new phase trait. No new .ad format: what's new is targetIdentityVerification: 'pre-dispatch' | 'post-resolution' on CommandDescriptor, pinning the evidence-carrying set ({click, press, longpress, fill, get, is} → pre-dispatch; wait → post-resolution) with a parity test. An annotated wait therefore structurally cannot enter the generic pre-action path — the issue's key acceptance criterion. Only ADR 0012 path 1 (recorded-unverifiable, which consults no screen) still refuses a wait before polling.
  • Wait verifies in-loop, keep-polling, identity tier only. Success strengthens from "selector matches" to "a selector match carries the recorded identity (local identity + leaf-anchored ancestry prefix)". A same-selector impostor never aborts the wait mid-poll (transient look-alikes during screen transitions are what waits exist to wait through); at the deadline, rejected candidates become a fail-closed identity-mismatch REPLAY_DIVERGENCE (matchCount from the last matching poll), while a never-matched selector stays the plain timeout (action-failure → state-repair routing, unchanged). Positional signals (sibling/viewportOrder) are deliberately not compared — a guard proves the landmark exists, not that it kept its list position, and comparing position would false-fail legitimately reshuffled-but-correct screens.
  • Landmark evidence mode at record time. computeTargetEvidence(..., {mode:'landmark'}) self-checks identity-set membership (mirroring what replay verifies); an identity-empty match (no id, no label after Android replay: non-unique framework id android:id/title used as primary identity → systematic false divergences (FDR 1.0) #1269 demotion) records no annotation — fail-open to today's semantics, and publication then refuses it as a guard with a hint naming a labeled landmark.
  • is (except exists) = the exact get pattern: evidence from its resolved node, generic pre-dispatch verification, assertExpectedResolvedTarget guard threaded through dispatch. Direct-iOS wait/is fast paths are gated during recording and guarded replays (same rule get has had since feat: parse and preserve .ad target-v1 evidence (ADR 0012 migration step 3) #1196; authoring-only cost).
  • Explicitly deferred, with tests proving no annotation/no check: is exists, all read-only find variants (fuzzy-locator resolution has no selector-chain identity token for the classifier), wait text/stable/duration/@ref (wait @ref stays rejected by publication, never converted).
  • ADR 0016 guard: a qualifying destination guard is now a selector wait whose annotation is verification: "verified".

Wire/plumbing

The recorded landmark travels the daemon-only internal.replayLandmarkGuard channel (unspoofable, same as replayTargetGuard); the loop's deadline refusal is reason: wait_landmark_identity_mismatch with a compact {matchCount, observed, observedAncestry} payload, converted by the step loop into the standard bounded divergence (shared shaping with the guard-mismatch path via buildPostDispatchIdentityMismatchResponse). node/preActionNodes are stripped before both session.actions and the wire on every path.

Validation

  • Unit: wait loop semantics (impostor-then-landmark success proving keep-polling; deadline mismatch payload; plain timeout preserved; unannotated unchanged), landmark evidence mode (twins verified, identity-empty omitted, broken-walk unverifiable), trait parity, is record/verify/guard, read-only find + is exists deferral proofs.
  • Daemon: annotated wait dispatches with the guard and no pre-action capture; recorded-unverifiable wait refuses pre-poll with matchCount omitted; loop refusal → identity-mismatch divergence with repairHint: caution; duration-wait annotation inert.
  • Provider (end-to-end): the ADR 0016 reshuffled-screen false-pass regression — record → session save-script (annotation lands in the .ad) → replay against a same-label/different-ancestry tree → REPLAY_DIVERGENCE identity-mismatch with matchCount ≥ 1 (the proof the selector alone would have false-passed).
  • Gates: full vitest suite ×3 across the arc, tsc, oxlint, oxfmt, layering, fallow (complexity/duplication/production-exports), plus an independent adversarial review pass over the whole diff (no findings; two informational observations adjudicated in the ADR text/code comments).

Perf / size

No measurable regression: live unannotated wait polls differ by one small allocation; the annotated-replay identity check is O(tree) per poll, dwarfed by the poll's own snapshot capture. No new startup-parse weight (the commands bundle already reached the identity modules; the new shared module is ~80 dependency-free lines). The one deliberate cost: recording sessions use the snapshot path for wait/is instead of the direct-iOS runner query, which evidence capture requires.

Docs: ADR 0012 decision-3 amendment (#1349) + migration row 11, ADR 0016 amendment, help workflow/session save-script text.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.82 MB 1.83 MB +4.3 kB
JS gzip 582.3 kB 583.8 kB +1.5 kB
npm tarball 696.5 kB 698.0 kB +1.5 kB
npm unpacked 2.44 MB 2.45 MB +4.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.9 ms 28.2 ms +0.3 ms
CLI --help 59.8 ms 58.6 ms -1.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runtime.js +1.1 kB +450 B
dist/src/session.js +1.3 kB +449 B
dist/src/selector-runtime.js +464 B +120 B
dist/src/cli-help.js +316 B +116 B
dist/src/sdk-batch.js +369 B +59 B

@thymikee

thymikee commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head 323e3dac; the replay implementation traces cleanly, but the PR is not ready:

  1. P2 — remove unrelated generated Android binaries. The first replay refactor commit also adds android/multitouch-helper/build/classes/** and android/multitouch-helper/build/dex/classes.dex. These derived artifacts are unrelated to Design read-only identity verification without breaking wait polling #1349, and analogous helper build directories are ignored. Remove them from this PR and add the matching ignore rule if this output is routinely generated.

  2. Readiness blocker — required live backend evidence is missing. The mocked/provider reshuffled-tree regression is strong and non-vacuous, but ADR 0016 explicitly requires live iOS and Android runs proving the saved artifact, identity-guarded destination wait, post-save handoff, and subsequent live-session command on real backends. Please attach those runs.

Descriptor phase → replay verifier → daemon-only landmark guard → snapshot-polling wait → bounded divergence conversion otherwise looks correct: impostors keep polling, ordinary timeout remains intact, and guarded recording/replay disables direct iOS fast paths as intended. Exact-head CI is green and merge state is clean. No ready label.

@thymikee

Copy link
Copy Markdown
Member Author

Both points addressed at head c249bcdde.

1. P2 — stray Android binaries: fixed

0aa50269e untracks android/multitouch-helper/build/** (swept in by the refactor commit's git add -A) and adds android/multitouch-helper/{build,dist}/ to .gitignore, matching the snapshot-helper/ime-helper rules.

2. Live backend evidence — attached, and it earned its keep

Ran the full ADR 0016 loop on real backends (isolated AGENT_DEVICE_STATE_DIR daemons, CLI built from this branch).

Android — Pixel 9 Pro XL API 37 emulator, com.android.settings:

  • open --relaunch --save-scriptpress "Network & internet"wait 'text="Internet"'session save-script: published artifact carries action-mode target-v1 on the press and landmark-mode verification:"verified" evidence on the guard wait, computed from the real device tree.
  • Post-save handoff: get text 'text="Internet"' on the still-live session → Internet.
  • Cold replay: deterministic 4/4 green, repeated runs — including the in-loop landmark verification on both waits.
  • The green replay required a live-found fix (below): before it, the cold replay deterministically died at the guard wait, and the merge-base build (00ef28734, with helper artifacts supplied) fails the identical artifact at the identical step — the exact failure class this live-validation requirement exists to catch.

iOS — iPhone 17 Pro simulator:

  • com.apple.Preferences artifact: publishes with id-led press evidence (com.apple.settings.general) + landmark guard; post-save get text handoff ✓. Cold replay correctly refuses with identity-mismatch: iOS Settings restores its nav stack on relaunch, so label="General" matches only the General screen's nav-bar title — a different identity than the recorded cell — and replay declines the wrong-screen tap. That's the live negative-path proof (ADR 0012's canonical nav-persistence divergence, caught by identity rather than false-passed by selector existence).
  • com.callstack.agentdevicelab artifact (authored with a launch-landmark wait after open, i.e. the guard pattern applied to the start screen): full cold replay green — Replayed 4 steps in 24.0s, the launch wait riding ~19s of sparse-AX settling before the identity-verified press and destination guard. Repeat runs flap on the pre-dispatch verification capture when the machine is loaded (sparse private-ax fallback) — that's the pre-existing feat(replay): ADR 0012 migration step 4 — target-binding verification enforcement #1209 path failing closed as designed, filed as Pre-dispatch identity verification races app launch: step-2 capture lands mid-transition and fails closed #1385.

Live-found product fix shipped in c249bcdde: a wait poll whose capture returned the Android helper's "insufficient foreground app content" verdict threw out of the polling loop — so any destination guard replayed right after a navigation press died on its first mid-transition capture (deterministic on the emulator). iOS already surfaces the same state as a sparse verdict with no matches and polls on; the fix makes wait semantics platform-consistent via isUnreadableCaptureContentError: content-verdict capture failures count as no-match polls, and a wait whose screen never became readable rethrows the last capture verdict at the deadline (persistent breakage keeps its diagnosis; other capture errors still throw immediately). Unit-tested (ride-out → success, never-readable → verdict rethrow, readable-no-match → plain timeout, non-content error → immediate), plus the before/after deterministic live repro, plus one ADR 0012 amendment sentence.

Pre-existing gaps found live, reproduced at merge base, filed instead of absorbed:

Exact-head CI is green apart from the in-flight run for c249bcdde.

🤖 Addressed by Claude Code

@thymikee
thymikee force-pushed the claude/agent-device-1349-design-fd6ed3 branch from c249bcd to e1f601c Compare July 24, 2026 11:01
thymikee added 7 commits July 24, 2026 13:11
…c/replay

Move buildIndexMap/buildAncestryChain/filterIdentitySet out of the daemon's
session-target-evidence into the shared replay zone so the commands runtime
(wait's polling loop, #1349) can consume them without importing the daemon;
press-retarget drops its private buildIndexMap duplicate.
…attern coverage for is (#1349)

- New CommandDescriptor trait targetIdentityVerification pins the
  evidence-carrying command set and routes wait to a post-resolution
  phase so an annotated wait never enters the generic pre-dispatch
  verification (an absent landmark is its expected starting condition).
- wait <selector> records landmark-mode target-v1 evidence (existence
  self-check; identity-empty matches record no annotation) and, on
  replay, keeps polling until a selector match carries the recorded
  identity; a deadline with only impostor matches fails closed as an
  identity-mismatch REPLAY_DIVERGENCE, a recorded-unverifiable
  annotation refuses before polling, and a plain timeout stays an
  action-failure divergence.
- is (except exists) joins the get pattern: evidence at record time,
  generic pre-dispatch verification, and the post-resolution guard
  threaded through dispatch; direct-iOS fast paths for wait/is are
  gated during recording and guarded replays.
- Read-only find stays intentionally unannotated (fuzzy-locator
  resolution has no selector-chain identity token), proven by test.
…ark identity (#1349)

A qualifying ADR 0016 guard is now a selector wait whose target-v1
annotation is verified; identity-less or unverifiable guards are refused
with a recovery hint. Adds the reshuffled-screen false-pass regression:
record -> publish -> replay against a same-label/different-ancestry tree
diverges as identity-mismatch (matchCount >= 1 proving the selector
alone would have false-passed).
…m evidence-writer complexity

Shared buildPostDispatchIdentityMismatchResponse behind the guard and
wait-landmark conversions; extracted payload-ceiling helpers from
computeTargetEvidence; identity-refusal conversion split out of
resolveReplayStepResponse. Docs: ADR 0012 decision 3 amendment (#1349),
ADR 0016 guard strengthening, help workflow/save-script text.
…xplicit, trim ADR-restating docs

The landmark-mode self-check was provably a tautology (the winner is a
member of its own identity set whenever the parent walk is intact), so a
membership scan defended only by a comment is replaced with the explicit
decision: broken walk fails closed, landmark is verified by construction,
action mode keeps decision 3's step-5 self-check. Doc comments that
re-argued the ADR amendment now state behavior and point to it.
…st dirs

Generated Android helper output swept into the earlier refactor commit by
accident; analogous snapshot-helper/ime-helper build dirs were already
ignored.
…ve-validated on Android)

Live ADR 0016 validation on a Pixel emulator showed a destination-guard
wait replayed immediately after a navigation press deterministically
dies: the first poll's capture lands mid-transition and the Android
helper's 'insufficient foreground app content' verdict threw out of the
polling loop. iOS already yields the same state as a sparse verdict
with no matches, so the loop kept polling there — this makes wait
semantics platform-consistent. A content-verdict capture failure
(isUnreadableCaptureContentError) now counts as a no-match poll for
selector and text waits; a wait whose screen never became readable
rethrows the last capture verdict at the deadline, so persistent
breakage keeps its diagnosis. Other capture failures still throw
immediately.
@thymikee
thymikee force-pushed the claude/agent-device-1349-design-fd6ed3 branch from e1f601c to 88ba8cd Compare July 24, 2026 11:11
thymikee added a commit that referenced this pull request Jul 24, 2026
…nism failure

Address review feedback on #1386: the bounded retry was retrying every
thrown capture-failed, including permanent mechanism failures (e.g. a
missing helper artifact) that a retry can never fix, at the cost of the
full backoff budget before the identical divergence. Gate the retry on
the same signal Android's helper capture path already emits for this
exact distinction: retriable:true on a content-poor rejection
(rejectAndroidHelperContentUnavailable), unset on a permanent one
(androidSnapshotHelperUnavailableError). The non-throwing
sparse-snapshot verdict still always retries. Mirrors #1381's
isUnreadableCaptureContentError taxonomy for the wait keep-poll loop.

Also update the two #1385 tests to use a retriable-tagged error and add
a case proving a permanent failure fails on the first attempt.
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 88ba8cd. P1 — narrow unreadable-capture classification to actual content verdicts. isUnreadableCaptureContentError currently treats any string androidSnapshotHelperFailureReason as content-unreadable, but Android attaches that field to general helper mechanism failures too (timeouts, adb/helper failures, malformed output), not only content recovery. Selector/text waits therefore poll those failures until their deadline instead of failing immediately; the synthetic non-content test misses this because it omits the field used by the real wrapper. Match only the actual content-recovery reasons (empty-helper-output, system-window-only, content-poor-app-window) and add a realistic wrapped mechanism-error regression. The generated binaries are removed, live iOS/Android evidence is credible, and exact-head CI is green; no ready label until this is fixed.

… content verdicts

Android stamps androidSnapshotHelperFailureReason on mechanism failures
too (helper timeouts, adb failures, missing helper artifact — free-form
reason strings), so matching any string made waits poll those to their
deadline instead of failing immediately. The predicate now matches only
the enumerated content-recovery reasons, and
AndroidHelperContentRecoveryDecision derives its reason union from the
same list so a new content verdict cannot miss the predicate. Adds the
realistic wrapped mechanism-error regression the synthetic test missed.
@thymikee

Copy link
Copy Markdown
Member Author

P1 addressed at head f7ae97b2e — and it was a fair catch: it's the same over-breadth I flagged on #1386's retry gate, sitting in my own predicate.

  • isUnreadableCaptureContentError now matches only the enumerated content-recovery reasons (empty-helper-output, system-window-only, content-poor-app-window). Verified the leak paths you described: both androidSnapshotHelperCaptureError (timeouts/adb/malformed output) and androidSnapshotHelperUnavailableError (missing artifact) stamp the same details key with free-form strings — those now fail a wait immediately again.
  • Single enumeration, not two lists: AndroidHelperContentRecoveryDecision['reason'] in snapshot-content-recovery.ts now derives its union from the same const list the predicate consumes (AndroidContentRecoveryReason), so a future content verdict cannot be added to the classifier without the predicate seeing it — the "what enumerates N" shape rather than a copied string set.
  • Added the realistic regression the synthetic test missed: a wrapped mechanism error carrying androidSnapshotHelperFailureReason: 'instrumentation run timed out after 120000ms' (the real androidSnapshotHelperCaptureError shape) must fail the wait immediately, not poll to deadline. The bare-error test stays as the no-field case; the content-verdict ride-out tests are unchanged and green.

Gates: tsc, oxlint, oxfmt, layering (the new platforms → snapshot type import is the existing legal direction), fallow clean; wait/android/snapshot suites green (371 tests across the affected dirs).

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head f7ae97b. The implementation correctly narrows content-unreadable capture reasons to the shared enumeration and prior live evidence remains valid, but the new mechanism-failure regression is vacuous: it only asserts the eventual error message. Reverting to the old broad classifier would poll the same repeated fixture error until the fake-clock deadline, rethrow it, and still pass. Assert exactly one capture attempt (or no clock/poll advancement) so reverting the classifier fails the test. Exact-head CI is green and the branch is conflict-free; no ready label until the regression proves fail-fast behavior.

…-sensitive

Assert exactly one capture attempt: the broad any-string classifier
would poll the repeated fixture error to the fake-clock deadline and
rethrow the same message, passing the message-only assertion. Verified
the mechanism test fails against the broadened classifier and passes
against the narrowed one.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at head 865aad213. Right call — the regression was revert-insensitive exactly as you traced it (broad classifier → poll to fake-clock deadline → rethrowIfNeverReadable surfaces the same message → assertion passes anyway).

Both fail-fast tests (the bare non-content error and the realistic wrapped mechanism error) now count capture attempts through a shared throwing-device helper and assert exactly one attempt — the fail-fast claim, not just the eventual message. Verified revert-sensitivity directly: with the classifier temporarily broadened back to any-string, the mechanism regression fails (17 attempts ≠ 1) while everything else passes; restored, all 26 file tests green. Gates clean (tsc, oxlint, oxfmt, fallow).

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 865aad21. Clean and ready for human review. The mechanism-failure regression is now non-vacuous: it asserts exactly one capture attempt, so reverting to the former broad classifier would poll and fail the test. The targeted change is test-only and all current checks are green; prior live evidence remains applicable.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 24, 2026
@thymikee
thymikee merged commit 9b610fb into main Jul 24, 2026
24 checks passed
@thymikee
thymikee deleted the claude/agent-device-1349-design-fd6ed3 branch July 24, 2026 18:05
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-24 18:05 UTC

thymikee added a commit that referenced this pull request Jul 24, 2026
* origin/main:
  feat(replay): recorded landmark identity for wait, is coverage — read-only step identity (#1349) (#1381)

# Conflicts:
#	docs/adr/0012-interactive-replay.md
#	src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design read-only identity verification without breaking wait polling

1 participant