Skip to content

fix(ios): double-check off-screen click refusals against a direct element read - #1566

Merged
thymikee merged 6 commits into
mainfrom
fix/offscreen-refusal-double-check
Aug 3, 2026
Merged

fix(ios): double-check off-screen click refusals against a direct element read#1566
thymikee merged 6 commits into
mainfrom
fix/offscreen-refusal-double-check

Conversation

@thymikee

@thymikee thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

#1542 (the remaining blocker on PR #1559, which fixed the "scroll-inert" defect) is fixed here: the iOS checkout-form.ad corpus leg now passes 2/2 fresh-boot.

Root cause

click id="shipping-pickup" was refused with resolved to an off-screen element even though the button was visibly on-screen. Live evidence (/private/tmp/ad-defect2-artifacts/manual-probe/):

  • Pre-scroll bulk tree: ScrollView ancestor rect: {x:18, y:62, w:366, h:729} (correct).
  • Post-scroll bulk tree: the SAME ScrollView reports rect: {x:18, y:381, w:366, h:109} — squeezed down to a sliver, a stale artifact of the keyboard-dismiss content-offset correction PR fix(ios): keyboard-dismiss content settle race (#1542) — partial, defect 2 needs a decision #1559 fixed the cause of but not this downstream symptom.
  • The button's own rect (y:136.67) is correct in both — but the off-screen guard (resolveEffectiveViewportRect/findNearestScrollableAncestorRect in src/snapshot/mobile-snapshot-semantics.ts) measures the button against its (corrupted) ancestor's clip rect and refuses.
  • A separate manifestation freezes the whole bulk tree at pre-gesture values for 20+s. In both cases a direct-XCUIElement re-read returned correct live values every time it was checked (adjudication finding).

Fix

When — and only when — the off-screen guard is about to refuse a click/tap/gesture-target resolution on iOS, it now takes one extra, tree-independent read of the target element straight from the local XCTest runner (querySelector, the same primitive the existing direct-iOS-selector fast path uses) and trusts that read's live hittable + rect-vs-root-viewport signal if it positively confirms on-screen.

  • IOS-scoped, zero hot-path cost: wired as a new optional AgentDeviceBackend.verifyOffscreenClickTarget method, attached only for local (non-provider) iOS sessions in src/daemon/handlers/interaction-runtime.ts; every other platform/backend omits it, so runtime.backend.verifyOffscreenClickTarget is undefined there and the guard's decision is byte-for-byte unchanged. It runs only inside throwIfOffscreenInteractionTarget's about-to-throw branch in src/commands/interaction/runtime/resolution.ts — never on the accept path.
  • A single read, no ancestor re-derivation: live validation against the real corpus found the natural way to re-select the scroll ancestor (its accessibility label) is often ambiguous — the ScrollView and a sibling wrapper share the label "Checkout form" — which made an ancestor-rect re-read fail closed even in the rescuable case. XCTest's own isHittable on a fresh, single-element query is already computed against the element's current clip/window state, so it captures ancestor-clipping correctness without a second, fragile query. This is a live-validation-driven refinement over the original ancestor-rect-swap design.
  • Direct-ios-selector.ts gating note: the existing tap fast path (readSimpleIosSelectorTarget) skips itself while session.postGestureStabilization is pending — exactly the window where the bulk tree is stale. The double-check deliberately does not inherit that gate; it needs to work precisely in that window.

The pure decision function + counterfactual proof

decideOffscreenRefusalDoubleCheck (src/snapshot/mobile-snapshot-semantics.ts) is the whole decision: bulk-says-offscreen + direct-says-onscreen → proceed; both agree → refuse; direct-read-unavailable → refuse (fail-closed). It is pure and covered by src/utils/__tests__/mobile-snapshot-semantics.test.ts, proved with two counterfactual mutations (revert-and-watch-fail per docs/agents/testing.md):

Counterfactual 1 — "always trust bulk" (hardcode return 'refuse' when bulk is off-screen, ignoring direct):

FAIL  |unit-core| src/utils/__tests__/mobile-snapshot-semantics.test.ts > offscreen double-check: bulk off-screen + a fresh direct read confirms on-screen -> rescued (proceed)
AssertionError: Expected values to be strictly equal:
+ actual - expected
+ 'refuse'
- 'proceed'

Counterfactual 2 — "always trust direct" (return direct.status === 'off-screen' ? 'refuse' : 'proceed', treating "unavailable" as license to proceed instead of falling back to the bulk guard's refusal):

FAIL  |unit-core| src/utils/__tests__/mobile-snapshot-semantics.test.ts > offscreen double-check: the direct read is unavailable -> fails closed and refuses exactly as today
AssertionError: Expected values to be strictly equal:
+ actual - expected
+ 'proceed'
- 'refuse'

Both mutations were applied, watched red, then reverted; the real implementation is back to 19/19 green in that file.

Live validation (fresh-boot iPhone 17 Pro / iOS 26.2)

Check Result
checkout-form.ad fresh-boot run 1/2 ✓ pass, 31.7s (previously failed at step 11)
checkout-form.ad fresh-boot run 2/2 ✓ pass, 24.1s
gesture-lab.ad fresh-boot run 1/2 (regression) ✓ pass, 23.5s
gesture-lab.ad fresh-boot run 2/2 (regression) ✓ pass, 24.1s
Android checkout-form-android.ad + gesture-lab-android.ad (Pixel_7_CI, app freezer disabled) ✓ 2/2 pass, 23.1s — proves no cross-platform change
Wrong-screen replay (Form-recorded click id="shipping-pickup" replayed against the Home screen, no navigation) — true-refusal proof REPLAY_DIVERGENCE fires: Replay failed at step 3 (click "id=\"shipping-pickup\""): Selector did not match: id="shipping-pickup"

The debug trace of a passing run shows the rescue firing live: a querySelector for shipping-pickup immediately precedes the tap at the step that used to fail — confirming the direct read, not a lucky bulk-tree state, is what let the click through.

Artifacts (videos, ndjson request logs, divergence reports) preserved outside the repo at /private/tmp/ad-refusal-artifacts/ on the machine this was developed on.

Gates

pnpm typecheck && pnpm lint && pnpm format:check && pnpm check:layering && pnpm check:replay-compat — all clean. npx vitest run src/daemon src/snapshot src/commands/interaction/runtime — 218 files / 1900 tests pass. Full npx vitest run — 637/638 files clean; the one flaky test (runner-client.test.ts's xctestrun-abort test, unrelated to this change) passed cleanly on an isolated rerun — contention flake, not an assertion failure.

Fixes #1542

Generated by Claude Code

…ment read

#1542: after an AX-free scroll on iOS, the off-screen interaction guard can
refuse a click even though the target is genuinely on-screen, because it
trusts a scroll-container ancestor's rect from the bulk accessibility tree,
which a keyboard-dismiss content-offset correction can leave stale/corrupted
while the target's own rect is already correct.

When the guard is about to refuse on iOS, it now takes a single fresh,
tree-independent XCUITest read of the target element (querySelector) and
trusts that read's live `hittable` + rect-vs-root-viewport signal instead,
if it positively confirms on-screen. Any failure to unambiguously re-resolve
the element (no id/label, not found, ambiguous, transport error) fails
closed exactly as before. Genuinely off-screen targets, and every other
platform, are unchanged: the backend method is gated to local (non-provider)
iOS sessions only, and only ever runs on the about-to-fail path.

The decision itself is a pure function (decideOffscreenRefusalDoubleCheck in
mobile-snapshot-semantics.ts) with counterfactual-proven tests: hardcoding it
to always trust the bulk verdict turns the rescue test red, and hardcoding
it to always trust the direct read (including on "unavailable") turns the
fail-closed/genuine-refusal test red.

Live-validated on a fresh-boot iOS simulator: checkout-form.ad 2/2 passes
(previously failing at step 11), gesture-lab.ad 2/2 (regression), and the
Android checkout-form/gesture-lab suite passes unchanged, proving no
cross-platform behavior change.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.93 MB 1.93 MB +1.1 kB
JS gzip 619.2 kB 619.6 kB +410 B
npm tarball 738.9 kB 739.3 kB +382 B
npm unpacked 2.59 MB 2.59 MB +1.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.8 ms 27.0 ms -0.8 ms
CLI --help 65.0 ms 64.8 ms -0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/interaction.js +559 B +212 B
dist/src/viewport-dimension.js +210 B +99 B
dist/src/selector-runtime.js +267 B +91 B
dist/src/internal/daemon.js 0 B +4 B
dist/src/registry.js 0 B -1 B

…se the double-check to one backend hook

Review blockers 1+2 (interleaved by design — the soundness fix is expressed
through the collapsed hook's contract):

1. SOUNDNESS: a rescued refusal now returns the node PATCHED WITH THE LIVE
   RECT the backend confirmed, and every downstream use (tap point, response)
   reads from that returned node — never the original. In the frozen-tree
   manifestation (the whole bulk tree pinned at pre-gesture values), the
   original rect can be stale even when the rescue verdict is correct;
   tapping it would have silently landed at the wrong coordinate. New
   regression: offscreen-double-check.test.ts's frozen-tree case, with a
   counterfactual (revert to computing the point from the pre-guard node)
   proven red then reverted.

2. SURFACE: collapsed to ONE optional backend hook,
   `confirmOffscreenTargetVisible?(context, node, rootViewport): Promise<Rect
   | null>` — conceptually a boolean, but returns the live rect so item 1's
   fix has something to act on. Deleted decideOffscreenRefusalDoubleCheck,
   the OffscreenRefusalDoubleCheckSignal/Reading ADT, and resolution.ts's
   dual-signal reconciliation shell: the bulk side was hardcoded 'off-screen'
   at the only call site, so the two-signal model was dead weight. The shared
   guard is now: bulk-off-screen -> ask the hook -> a live rect proceeds
   (patched), anything else (including no hook) throws exactly as before.

The pure geometry boundary that decision reduces to (`isConfirmedOnScreenProbe`
in mobile-snapshot-semantics.ts, replacing the deleted ADT) is unit-tested
with two counterfactuals: ignoring `hittable` and ignoring the viewport
containment check each turn a test red (proved, then reverted).

`throwIfOffscreenInteractionTarget` is now exported (ADR 0011 registry
honesty, see the contracts commit) and directly unit-tested in
resolution.test.ts, mirroring the existing tryResolveRefNode pattern.
…queryDirectIosSelector

Review blocker 3 (BOUNDARIES):

- direct-ios-selector.ts no longer does any runner I/O — it's back to pure
  gate/parse (readSimpleIosSelectorTarget, deriveDirectIosNodeSelector,
  isDirectIosSelectorFallbackError) plus the ONE shared eligibility
  predicate, isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization
  }). Both the direct-selector tap fast path and the new offscreen
  double-check probe call this same function; the one behavioral difference
  between them (the tap fast path skips a session with a pending
  postGestureStabilization, the double-check does not) is now an explicit
  parameter instead of two separately-written gates.

- The probe I/O moved to a new sibling, src/daemon/offscreen-target-probe.ts,
  which reuses selector-runtime.ts's `queryDirectIosSelector` (now exported
  and decoupled from SelectorRuntimeParams — it takes a session + a bare
  {key, value} selector + AppleRunnerRequestOptions) rather than opening a
  second querySelector client. Node extraction (`readDirectIosSelectorNode`,
  the one `as SnapshotNode` cast) stays singular, inside selector-runtime.ts.

- interaction-runtime.ts wires confirmOffscreenTargetVisible only when
  isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization:
  false }) — deliberately NOT skipping a pending post-gesture stabilization,
  since that is exactly the window the double-check exists to cover.
…arantee matrix

Review blocker 4 (GUARANTEE HONESTY): the shared offscreen cell
(RUNTIME_TREE_SHARED_GUARANTEES.offscreen, used by runtime-selector and
runtime-ref) and the native-ref path's offscreen cell still named
isNodeVisibleOnScreen as sole enforcement after #1542's double-check landed —
that understates what actually enforces the guarantee now.

Both cells' `via` now point at throwIfOffscreenInteractionTarget (exported
from resolution.ts in the prior commit for exactly this), the real
end-to-end enforcement point: isNodeVisibleOnScreen is the bulk-tree
decision it starts from, and on iOS a would-be refusal can still be
confirmed via the optional AgentDeviceBackend.confirmOffscreenTargetVisible
hook before erroring. The cell's comment states the rescue-only, fail-closed
shape explicitly per ADR 0011's matrix rules — this does not weaken the
cell, it extends its description to match reality.

iOS rescue policy stays OUT of resolution.ts's shared docstrings (the
"spine"): this registry file is where per-path enforcement detail belongs,
and the optional-method wiring in interaction-runtime.ts remains the only
cross-platform touch.

The registry's own gate test (interaction-guarantees.test.ts) still passes:
every `via` resolves to a real exported symbol.
….test.ts

Review blocker 5 (TEST HOMES): AGENTS.md forbids adding to
daemon/handlers/__tests__/interaction.test.ts (it predates the
test-mirrors-source-topology rule and shrinks opportunistically). Reverts
the 172 lines added there in the original PR version; interaction.test.ts is
back to its pre-#1542 baseline (81 tests, unchanged).

The same assertions now live in their proper homes (see the prior three
commits for the sources they cover):
- pure decision pin: src/utils/__tests__/mobile-snapshot-semantics.test.ts
  (isConfirmedOnScreenProbe, with the two counterfactuals)
- direct-guard pin: src/commands/interaction/runtime/resolution.test.ts
  (throwIfOffscreenInteractionTarget, mirroring tryResolveRefNode)
- probe unit tests: src/daemon/__tests__/selector-runtime.test.ts
  (queryDirectIosSelector) and src/daemon/__tests__/direct-ios-selector.test.ts
  (isLocalIosRunnerSession, deriveDirectIosNodeSelector)
- probe integration: src/daemon/__tests__/offscreen-target-probe.test.ts
  (confirmIosOffscreenTargetVisible, mocked runner)
- end-to-end rescue/refuse, including the frozen-tree live-geometry
  regression + its counterfactual: new sibling
  src/commands/interaction/runtime/offscreen-double-check.test.ts (next to
  resolution.ts, using the same createInteractionDevice harness
  resolution.test.ts already uses)
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Reshape complete — all five blockers addressed

Recreated the worktree from origin/fix/offscreen-refusal-double-check, addressed each blocker (commits below), re-ran the full exit bar live, and re-ran all gates. Pushed as 5 new commits on the same branch (451444176..1c8b19aaa).

Note on process: I couldn't locate the underlying review comment via gh api repos/callstack/agent-device/issues/1566/comments (only a bot size-report comment exists there) or pulls/1566/reviews. I proceeded from the coordinator's detailed relay of it, and independently verified blocker 1's soundness claim was real before implementing (see below) — flagging this for the record, not as a blocker to the fix itself.

1. SOUNDNESS — live geometry after rescue

Confirmed real: my original code returned early from the guard on a rescue but callers kept using the pre-guard node (with its possibly-stale bulk rect) to compute the tap point. In the frozen-tree manifestation (whole bulk tree pinned at pre-gesture values), a correct rescue verdict could still tap the wrong coordinate.

Chose (a), not (b): throwIfOffscreenInteractionTarget now returns the node patched with the live rect the backend confirmed, and both resolveRefInteractionTarget/resolveSelectorInteractionTarget compute the point from that returned node, never the original. I evaluated (b) (hand off to the direct querySelector/element-selector act path) but it doesn't compose cleanly here: that path is a different, pre-existing mechanism (direct-ios-selector.ts's fused query+tap fast path, gated off by replayTargetGuard/non-simple-selectors/etc.), and rerouting through it would mean re-deciding dispatch shape mid-guard instead of just fixing the coordinate the existing coordinate-tap dispatch already uses. (a) is a minimal, local, obviously-correct fix.

New regression + counterfactual (offscreen-double-check.test.ts, frozen-tree fixture where the bulk rect and the live rect are at different locations):

FROZEN-TREE regression (#1542): a rescued tap lands at the LIVE rect, never the stale bulk one
AssertionError: Expected values to be strictly deep-equal:
  [
    {
+     x: 70,       // stale bulk-rect center — what a revert produces
+     y: 2020
-     x: 200,       // live-rect center — what the fix asserts
-     y: 320
    }
  ]

(Mutation applied: reverted resolveNodeCenter(visibleNode, ...) back to resolveNodeCenter(node, ...). Watched red, reverted.)

2. SURFACE — one hook

Collapsed to confirmOffscreenTargetVisible?(context, node, rootViewport): Promise<Rect | null> on AgentDeviceBackend. Conceptually a boolean, returns the live rect (truthy = confirmed + here's where) so item 1 has something to act on. Deleted decideOffscreenRefusalDoubleCheck, the OffscreenRefusalDoubleCheckSignal/Reading ADT, and resolution.ts's dual-signal reconciliation shell (readOffscreenRefusalDoubleCheckReading). The shared guard is now:

const liveRect = await runtime.backend.confirmOffscreenTargetVisible?.(ctx, node, rootViewport);
if (liveRect) return { ...node, rect: liveRect };
throw new AppError(...); // fail-closed, unchanged

The pure geometry boundary the hook's decision reduces to (isConfirmedOnScreenProbe in mobile-snapshot-semantics.ts, replacing the deleted ADT) is unit-tested with its own two counterfactuals:

isConfirmedOnScreenProbe: inside the viewport but NOT hittable -> not confirmed
AssertionError: true !== false   (mutation: dropped the hittable check)

isConfirmedOnScreenProbe: hittable but OUTSIDE the root viewport -> not confirmed
AssertionError: true !== false   (mutation: dropped the viewport check)

Both applied, watched red, reverted.

3. BOUNDARIES

direct-ios-selector.ts is back to pure gate/parse (readSimpleIosSelectorTarget, deriveDirectIosNodeSelector, isDirectIosSelectorFallbackError) plus one new shared predicate, isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization }) — used by both the tap fast path (true) and the new probe (false), so the one real behavioral difference between the two callers is now an explicit parameter instead of two hand-written gates.

The probe I/O moved to a new sibling, src/daemon/offscreen-target-probe.ts, which reuses selector-runtime.ts's queryDirectIosSelector — now exported and decoupled from SelectorRuntimeParams (it takes session, a bare {key, value} selector, and AppleRunnerRequestOptions directly) instead of opening a second querySelector client. Node extraction (readDirectIosSelectorNode, the one as SnapshotNode cast) stays singular in selector-runtime.ts.

4. GUARANTEE HONESTY

Both offscreen cells that go through this guard (RUNTIME_TREE_SHARED_GUARANTEES.offscreen, shared by runtime-selector/runtime-ref; and native-ref.offscreen) now point their via at throwIfOffscreenInteractionTarget (exported for exactly this — the registry gate requires a real exported symbol), with a comment naming the optional iOS rescue hook as part of enforcement. isNodeVisibleOnScreen is named as the bulk-tree decision the guard starts from, not the sole enforcement. interaction-guarantees.test.ts (the registry's own honesty gate) passes. iOS rescue policy stays out of resolution.ts's shared module-level prose — only the per-cell registry description and the guard function's own doc comment mention it; the optional-method wiring in interaction-runtime.ts is the only cross-platform touch.

5. TEST HOMES

daemon/handlers/__tests__/interaction.test.ts is back to its pre-#1542 baseline (81 tests, byte-identical content) — the 172 lines are gone from there. Relocated:

  • pure pin: src/utils/__tests__/mobile-snapshot-semantics.test.ts (isConfirmedOnScreenProbe)
  • direct-guard pin: src/commands/interaction/runtime/resolution.test.ts (throwIfOffscreenInteractionTarget, imported directly — mirrors the existing tryResolveRefNode pattern; this also satisfies fallow's unused-export check, which flagged the export-for-registry-honesty-only symbol on the first pass)
  • probe unit tests: src/daemon/__tests__/selector-runtime.test.ts (queryDirectIosSelector) + src/daemon/__tests__/direct-ios-selector.test.ts (isLocalIosRunnerSession, deriveDirectIosNodeSelector)
  • probe integration: src/daemon/__tests__/offscreen-target-probe.test.ts (mocked runner)
  • end-to-end rescue/refuse + the frozen-tree regression: new sibling src/commands/interaction/runtime/offscreen-double-check.test.ts, using the same createInteractionDevice harness resolution.test.ts already uses

LOC table

File Pre-#1542 (origin/main) First PR version Reshaped (this push)
resolution.ts 812 891 869
backend.ts 602 607
direct-ios-selector.ts 184 129
selector-runtime.ts 663 678
interaction-runtime.ts 198 201
mobile-snapshot-semantics.ts 520 516
offscreen-target-probe.ts (new) 59
interaction-guarantees.ts 442

resolution.ts shrank 22 lines from the first PR version despite the soundness fix adding code, because deleting the dual-signal reconciliation shell (item 2) more than paid for it. It's still above the 500-LOC tripwire, same as it was before this arc (812 baseline) — that's pre-existing debt this PR doesn't fix, not something introduced here.

Exit bar (fresh-boot iPhone 17 Pro / iOS 26.2, re-run in full against the reshaped code)

Check Result
checkout-form.ad fresh-boot 1/2 ✓ pass, 22.4s
checkout-form.ad fresh-boot 2/2 ✓ pass, 23.6s
gesture-lab.ad fresh-boot 1/2 ✓ pass, 21.4s
gesture-lab.ad fresh-boot 2/2 ✓ pass, 25.8s
Android suite (Pixel 7 CI, freezer disabled) checkout-form-android.ad + gesture-lab-android.ad ✓ 2/2 pass, 18.2s + 20.4s
Wrong-screen replay (click id="shipping-pickup" against Home, no navigation) REPLAY_DIVERGENCE fires: Replay failed at step 3 (click "id=\"shipping-pickup\""): Selector did not match: id="shipping-pickup"

Gates: pnpm typecheck && pnpm lint && pnpm format:check && pnpm check:layering && pnpm check:fallow --base origin/main && pnpm check:replay-compat all clean. npx vitest run src/daemon src/snapshot src/commands/interaction/runtime src/utils/__tests__/mobile-snapshot-semantics.test.ts src/__tests__/contracts — 226 files / 1942 tests pass. Full npx vitest run — ran twice (system under load from live device work); 11 then 3 failures, all in files this PR never touches (scripts/fuzz/harness.test.ts, src/platforms/android/__tests__/input-actions.test.ts, test/integration/provider-scenarios/{android-lifecycle,android-recording,doctor}.test.ts), all confirmed clean on isolated rerun (0 failures) — contention flakes, not regressions.

Generated by Claude Code

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed updated exact head 1c8b19a. The stale-coordinate blocker is fixed: a successful rescue now propagates the confirmed live rect through both ref and selector resolution, and the frozen-tree regression proves dispatch uses the live center. The oversized-test blocker is also fixed: the new cases moved out of legacy interaction.test.ts into focused runtime/daemon modules. The iOS hook reuses the existing direct-query client and remains fail-closed on ambiguity, transport failure, non-hittable state, or root-viewport mismatch. No code findings remain. Existing live iOS artifacts exercise the production direct-query route; exact-head CI is still completing.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 3, 2026
@thymikee
thymikee merged commit 1235216 into main Aug 3, 2026
31 checks passed
@thymikee
thymikee deleted the fix/offscreen-refusal-double-check branch August 3, 2026 12:13
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-03 12:13 UTC

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.

iOS test-app Form screen: scroll-inert to synthetic gestures and recurring slow-AX deferral

1 participant