Skip to content

feat(interaction): add the moving-target gate the tap path consults (#3471) - #3538

Merged
thymikee merged 3 commits into
mainfrom
feat/3471-moving-target-judgement
Oct 13, 2026
Merged

thymikee merged 3 commits into
mainfrom
feat/3471-moving-target-judgement

Conversation

@thymikee

@thymikee thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Part of #3471 (layer 1 of 3) — the judgement half of the moving-target gate; #3471 closes at the top layer.

A press resolves a point from one capture, and nothing between that capture and the dispatch ever looked at the surface twice — so a press issued right after an action that animated the UI can resolve a point the control has already vacated and report success: true on the vacancy. #3539 carries the base/head device numbers.

This layer is the decision and the seam, not the history it reads:

  • @agent-device/contracts/moving-target-gate — movingTargetGateDecision (is this position owed a second observation, from the session's mutation clock) and the rest question (movingTargetRectsStable), pure over values the caller already holds. Window 500 ms anchored on the mutation's end, sample spacing 50 ms. The rect band is not this module's fact to hold: fix(interaction): measure the moving-target gate by adoption, not bounds (#3471) #3548 hoists it to areCapturedRectsAtSamePosition in @agent-device/kernel/rect-center, which is the one place that decides whether two capture rects count as one position (the post-gesture no-effect comparison already ran that comparison against its own copy of the band). Landing here first and hoisting in the next layer is what feat(interaction): add the moving-target gate the tap path consults (#3471) #3538's review asked for; the stack merges together.
  • @agent-device/ad-script nodeRectByIdentity — the same-node lookup the screening needs, placed with the owner of local-identity matching rather than beside its only caller; fails closed when an identity is absent, shared, or unmeasurable.
  • src/commands/interaction/runtime/moving-target-gate.ts — the seam in tapCommand between resolution and runtime.backend.tap: waits out the window, re-observes twice, taps where the target came to rest, refuses with target_still_moving / dispatched: 'no'.

12 files, 958 gross. No behavior change here: nothing writes movingTargetBaseline yet, so every press answers idle and pays one session-record read — grep -rn --include='*.ts' movingTargetBaseline src packages/*/src scripts (excluding tests) returns only the type's declaration and this gate's own read. Each layer consumes what it adds. MovingTargetBaseline.tree is documented as the pre-action tree only; #3539 widens that doc when the writer proves the converged-settle case, because the doc cannot be written truthfully from this layer.

Validation

Tested at 3e6c29f44.

  • pnpm check:affected --run --base origin/main — result recorded in a comment on this PR. Selection is fail-open (the layer adds a packages/contracts/package.json export, which rewires package resolution and can alter any gate), so this is the full check set, taken under the machine-wide gate lock.
  • typecheck, lint, format:check, check:layering, check:production-exports, eager-closure-budgets — clean. The reason literal stays in @agent-device/selectors, the leaf every interaction adapter already reads, so the gate module keeps importing nothing new at module scope.
  • vitest run packages/ad-script packages/contracts src/commands/interaction — pass; each new gate test fails when its guard is reverted.
  • Device evidence is on fix(daemon): let a finishing mutation record the tree it acted through (#3471) #3539, where the refusal becomes reachable. Residual risk: this seam is unreachable in production until the top layer lands, so fix(daemon): let a finishing mutation record the tree it acted through (#3471) #3539 must land with this PR or immediately after it.

Guarantees

The ADR 0011 targetStability cells — the runtime enforcement on runtime-selector and runtime-ref, and the intentional waivers on the paths that hand off before the seam (direct-ios, native-ref, coordinate, the two Maestro routes, target-drag) — are declared in #3539, the layer that makes the guard reachable. A cell has to name a guarantee the shipped paths actually provide, and until the top layer writes movingTargetBaseline no press is screened.

@thymikee

Copy link
Copy Markdown
Member Author

pnpm check:affected --run at 3e64c33a8 — passed ("check:affected: all runnable checks passed").

Two earlier runs of the same commit at 19:08 and 19:35 each exited 1 on exactly one timing-sensitive test unrelated to this diff, under host load average 70–330 on 12 cores:

run failing test standalone re-run
19:08–19:25 src/daemon/handlers/__tests__/session-relaunch-close.test.ts — Test timed out in 5000ms (5,528 other tests passed) 18/18 pass in 2.4 s
19:53–19:59 same file, same test (5,645 other tests passed) 18/18 pass in 7.9 s

Both runs' own slow-test gate reports the same file over budget, the file is untouched by this stack (git log origin/main..HEAD -- src/daemon/handlers is empty), and the third run at lower load passed everything. Diagnosis: subprocess contention from other workers on this host, not a regression. Recorded per docs/agents/pull-requests.md ("diagnose it and rerun after the cause is resolved").

@thymikee

Copy link
Copy Markdown
Member Author

Design — moving-target gate (#3471)

The defect in one line

A press resolves a point from a capture, and if a mutation that started an animation finished
within the last few hundred milliseconds, that point can be where the control was. Nothing between
resolution and dispatch notices.

Where the "last mutating action finished at" timestamp and its pre-action tree live

SessionState.deferredSurface, as the recheck variant:

| { kind: 'recheck'; baseline: MovingTargetBaseline }   // { finishedAt, tree: SurfaceScopedNodes }

Owner: src/daemon/deferred-interaction-outcome.ts, declared in scripts/layering/session-state.ts.

It shares one field with the pre-existing stabilize variant (what was postGestureStabilization).
Not a new SessionState field and no ADR: both variants answer the same question — what does this
session still owe because of its last mutation, before its surface can be trusted again?
— and a
mutation leaves room for exactly one answer. The transitions, all in that one module, are: a
gesture-shaped mutation marks stabilize; a mutation that marks no stabilization but resolved a
target marks recheck; a device side effect retires recheck; a capture discharges stabilize.
Writing either retires the other, which is also how a gesture takes over a surface a press left
mid-animation. A recheck never overwrites an owed stabilize: an @ref press spends no capture,
so a scroll's quiet-window loop can still be pending when that press finishes, and erasing it would
drop the #1542/#1600 unsettled-tree check the scroll paid for.

Why these three pieces and why here:

  • markDeferredInteractionOutcome is already the repository's one "this mutation finished" seam —
    every mutating route calls it right after dispatch (touch, gesture, direct-iOS, the generic
    route, find's internal dispatch, and app open). Its own docblock already says the call happens
    "post-dispatch, pre-capture", so a field written from it is stamped at the instant the action
    finished, which is exactly the clock the issue asks for. No second mutation clock is introduced.
  • tree is the pre-action tree for free: the observation the action resolved its target against.
    That is the same free-baseline argument markPostGestureStabilization already uses. Zero extra
    captures. Where the action published a newer tree than the one it resolved against (--settle,
    --verify), that newer tree becomes the baseline instead — it is the better account of where the
    surface came to rest.
  • The gate asks about this element, not the whole tree. A ScrollView whose children all shift by
    800 px is not a moving button, so a whole-tree diff is both costlier and the wrong question. The
    screening predicate (nodeRectByIdentity in @agent-device/ad-script, the owner of local-identity
    matching) answers "where does this tree put this node's identity", and answers nothing when the
    tree cannot — absent, ambiguous, or unmeasurable all owe the re-observation. Positional coincidence
    never grants rest, which is the same fail-closed direction demoteNonUniqueLocalIdentity takes for
    a non-unique id.

Two transitions retire the baseline:

  • markSessionSnapshotOutdated (called by expireRefFrame, i.e. every device side-effect seam):
    the caller's observation is no longer the newest one, and a gate decision made against a
    superseded tree is the positional-coincidence retarget ADR 0014 forbids. A mutation that
    completes without crossing a seam (the fixture's slide-in) keeps the baseline; one that crosses it
    (a gesture, a generic-route scroll) does not, and pays nothing.
  • A newer stabilize mark: the capture side's wait supersedes the gate's re-check, and the gate
    would only re-do it.

Which paths the gate covers

Path Cell Where
runtime-selector runtime (appliesTo: press, click) tapCommand, between resolution and runtime.backend.tap
runtime-ref runtime (same cell) same single seam — an @ref press takes the runtime path unless a backend owns tapTarget
native-ref inapplicable the fast path activates a provider-owned semantic handle, not a point the daemon aimed from a capture; there is no daemon-captured position that could go stale
coordinate inapplicable raw x/y has no element to be stable; the path's own waiver already owns that
target-drag inapplicable endpoints name contact coordinates for one uninterrupted pointer plan (ADR 0013); re-pointing an endpoint would split the plan the gesture's integrity depends on
maestro-direct-selector, maestro-non-hittable-fallback waived the fused runner request queries the element and taps it inside one XCTest request, aiming at the element's own live frame at tap time — the capture-to-dispatch distance this gate closes does not exist on that route

Keeping the stage in the command layer (one call in tapCommand) rather than in packages/selectors's
per-row table is deliberate: the row table's invariant is "a row may only declare what these
runners enforce", and the only runner that could enforce a second tap would be a runner that does
not exist — a declared stage with no enforcer is the exact disease #1649 documents.

The decision, and why an idle press pays no extra capture

movingTargetGateDecision (packages/contracts/src/moving-target-gate.ts) is pure over values the
caller already holds and returns one verdict:

  • idle — the session has no baseline. No extra capture, ever.
  • not-recent — the last mutation finished MOVING_TARGET_GATE_WINDOW_MS (500) ago or more.
    No extra capture. This is the cost-budget answer: the gate reads one number from the session
    record on every press and captures only inside the window.
  • stationary — the previous action's own tree already shows this node at the resolved rect. Tap at
    the resolved point; no extra capture.
  • recheck — inside the window and disturbed: wait out the rest of it and take two fresh
    observations.

The window is anchored on the mutation's end (finishedAt), so the gate waits out only what is
LEFT of it. That is how "no cost when idle" holds, and why a guarded press is cheaper than the
--settle the same flow needs today.

MOVING_TARGET_GATE_SAMPLE_GAP_MS (50) is the minimum spacing between those two observations,
enforced before the first of them (waitMs = max(remaining window, 50)) and again between them. It
is not a "treat two close observations as one" rule: below roughly one capture round trip, two
observations of a still-moving target agree by timing rather than by the target having stopped, and
the gate would certify a position it never measured. Agreement taps at the re-confirmed position; a
target the window could not settle is refused, because a coordinate there is a guess.

Re-resolving rather than re-pointing is deliberate. The recheck spends the whole resolution path, so
every shared guard re-runs against the fresh observation and the response's identity fields describe
the node actually dispatched at. For an @ref that is also how ADR 0014's own reconciliation adopts
fresh coordinates for a same-identity node, so the gate never retargets a ref by positional
coincidence. One observation can never certify rest: a control that completed its slide also
"changed" against the tree captured before the animation, and refusing that tap would leave the gate
worse than no gate — an idle press has always trusted a single fresh capture. Two disagreeing
observations are the only motion claim the gate makes.

A @ref re-observation is measured, or it is nothing. A ref resolution deliberately spends no
capture, so the gate takes the observation first and reads the ref's own node in that capture; a
resolution that kept its authorized frame node disagrees with the capture and is reported unmeasured.
Comparing one stale rect to itself would certify rest by a non-measurement, the one outcome the gate
must never produce.

The typed refusal and its hint

error.details.reason: 'target_still_moving', added to INTERACTION_ERROR_REASONS beside
target_covered. The reason constant and the hint builder both live in the contracts module, and the
only producer raises them together, so the reason cannot be published without its recovery.
dispatched: 'no' through the existing discloseDispatch(error, 'no').

details: reason, rect (the position the target had at resolution), movedToRect (where the
last observation saw it, absent when that observation could not measure it), and selector or ref
— whichever the caller addressed the target by, mirroring the off-screen guard so a caller always
gets one of the two — plus waitedMs and hint.

The hint names the recovery that works: run wait '<selector>' and retry the same selector (an
@ref cannot survive the intervening wait because the wait expires the ref frame — same reasoning
the off-screen hint already carries), or pass an explicit --settle, which is the opt-in version of
the same discipline.

Deliberately not done

fill, longpress, hover are not gated. A stale tap point is one failure mode; stealing focus or
typing into a field that is mid-flight is another mechanism, and the issue's repro, Playwright's
pointer-action rule, and e2e's settled() are all press-shaped. longpress aims the same rect and
is the honest weak edge of that line — a hold that begins one frame early is still a gesture the
recognizer owns, and the measured failure and the caller-visible recovery are both tap-shaped. The
appliesTo scoping mechanism on the guarantee matrix exists for exactly this, and widening it later
is one declaration plus one scenario per added command. Stated in the PR rather than left implicit.


Design note for #3471 (layer 1 of 2). Both review passes are folded into this text: the same-node lookup lives with the owner of local-identity matching (nodeRectByIdentity in @agent-device/ad-script) rather than beside the gate's only caller, and the @ref observation rule reads the ref's own node in the capture it just took instead of a whole-tree identity match.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.19 MB 5.19 MB +2.7 kB
Package (unpacked) 5.19 MB 5.19 MB +2.7 kB
Package (download) 1.56 MB 1.56 MB +904 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.0 ms 27.5 ms +0.5 ms
CLI --help 76.9 ms 78.7 ms +1.8 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread src/commands/interaction/runtime/moving-target-gate.ts
Comment thread packages/contracts/src/moving-target-gate.ts
Comment thread packages/selectors/src/interaction-error.ts Outdated
Comment thread src/commands/interaction/runtime/moving-target-gate.ts
Comment thread src/commands/interaction/runtime/moving-target-gate.test.ts
Comment thread packages/contracts/src/moving-target-gate.ts
Comment thread packages/selectors/src/interaction-error.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

The gate logic looks sound at 3e64c33, but one finding needs a fix before merge. The Coverage check is red, and the failure comes from this diff: packages/selectors/src/interaction-error.ts now imports packages/contracts/src/moving-target-gate.ts, and that import breaks the eager-closure budgets for src/cli.ts and five other entries. The Mutants shards and Smoke Tests were still pending when I looked, so I can't tie them to this change yet.

movingTargetRectsStable in contracts copies the 1px tolerance and the comparison loop from interaction-surface-signature.ts, and the gate's own comment cites that file as the source. That leaves two copies of the "capture rounding is not motion" rule. If one band changes, the gate and the post-gesture no-effect check will disagree about the same pair of rects. Please keep one tolerance comparison (rect pair plus band) in the @agent-device/kernel rect module, next to areRectsApproximatelyEqual. Call it from both sites and delete the local constant and loop.

Not blocking, and you can take or leave these: declare targetStillMoving: 'target_still_moving' as a literal in INTERACTION_ERROR_REASONS, as every other entry is, and raise it from src/commands/interaction/runtime/moving-target-gate.ts. That drops the new selectors-to-contracts import and should clear the Coverage failure. Also, #3539 needs to land right after this PR, or together with it, so the seam is not merged without a writer. Its guarantees edit should also declare the paths the gate does not cover (native-ref, maestro-direct-selector, coordinate) as intentional or as gaps.

Could most of this live in the one consumer? Only the MovingTargetBaseline type needs to sit in contracts. The decision function, the window and gap constants and the hint could move to src/commands/interaction/runtime/moving-target-gate.ts. That would also remove the new contracts subpath and its package.json export. The reason literal would sit in the registry, and the gate would call the one hoisted kernel comparison. For the ref branch, could the gate judge measurement by provenance (resolved.preAction being the capture just taken), as its docstring says, instead of by ref-index rect coincidence?

I did not run the gate suite or mutate the guard, so the "each fails when reverted" claim is unverified here. I read only the file list of #3539, not its content.

The open inline threads still stand, and none of them is resolved. P1: ref measurement provenance and delayed-start animation (a delayed-start case belongs in #3539's live run). P2: unmeasured first recheck, surface bundle scoping, weak refusal assertion and selector quoting. One lower-priority thread also applies: stale owner comment, which goes away if the literal moves into the registry.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/contracts/src/moving-target-gate.ts Outdated
@thymikee
thymikee force-pushed the feat/3471-moving-target-judgement branch from 04be2b3 to 1626bc9 Compare October 12, 2026 20:46
@thymikee

Copy link
Copy Markdown
Member Author

Gate evidence at this layer's head

pnpm check:affected --run on 1626bc9ec — exit code 0.

$ pnpm check:affected --run
check:affected — diff origin/main...HEAD
Fail-open: selecting the full check set.
  ! packages/contracts/package.json [workflow-tooling] — workflow/tooling change can alter any gate
  ! scripts/layering/contracts-exports.snapshot.json [workflow-tooling] — workflow/tooling change can alter any gate
Selected 63 check(s):
...
[run] mutation-model: pnpm run mutation:test
...
check:affected: all runnable checks passed.
$ echo $?
0

Notes for whoever reads this later:

  • Selection is fail-open, so this layer's run is the full 63-check set rather than a diff-scoped subset. The trigger is real: this layer adds an export to packages/contracts/package.json, which rewires package resolution and therefore can alter any gate.
  • The lock: taken as /tmp/agent-device-full-gate.lock at 23:04 local, and released once this layer's slot finished, before the next layer queued. One full gate per host — this Mac has other workers holding the same lock, so the queue is the price of not competing with them for cores.
  • Earlier heads of this branch went red twice on src/daemon/handlers/__tests__/session-relaunch-close.test.ts (a 5,000 ms assertion) and on check:tmpdir-leaks:test. Both files are untouched by this stack, both passed standalone at the time, and load average during those runs was 70–330 while four other gate jobs ran. The head was not changed for those runs; the same suite passes inside this green run.
  • The string check:affected: lint failed. appears inside the captured log several times. It is check:affected's own unit tests, which drive runChecks with a fake executor that returns non-zero, and those tests run as a step of the gate. The real invocation's [run] lines and the final all runnable checks passed are the result.

Local-only runs recorded for completeness (CI is authoritative for the lanes skipped above): pnpm build, tsc --noEmit, pnpm lint, pnpm format:check, pnpm check:layering, pnpm check:production-exports, and vitest run over packages/contracts, packages/ad-script, src/commands/interaction, plus scripts/__tests__/eager-closure-budgets.test.ts.

@thymikee

thymikee commented Oct 12, 2026 •

Copy link
Copy Markdown
Member Author

Correction to the design note

The note above says:

Where the action published a newer tree than the one it resolved against (--settle, --verify), that newer tree becomes the baseline instead — it is the better account of where the surface came to rest.

That rule no longer exists as written. Review found nothing at the marking seam could tell a post-action capture from a @ref press's authorized frame tree or a freshness capture: all three reach session.snapshot as a node array that differs from the one the action resolved against. Inferring "newer" from array identity could store a tree the action never looked at — the substitution the module's own paragraph forbids — and the next press's stationary verdict would rest on positions from the wrong surface.

What the baseline records now is a tree the caller can vouch for, by one of two routes:

A settle that timed out, a converged settle that stored nothing, a lone --verify capture, a freshness capture and a @ref frame tree all take the first route, because none of them proves rest.

The intermediate state between "infer from array identity" and the above is worth recording, because it is the kind of simplification that reads well and breaks a contract: dropping the preference entirely made every baseline the pre-action tree, which broke test/integration/provider-scenarios/settle-observation.test.ts — the repository already encodes, on the runner transcript, that a press following a converged --settle spends a tap and nothing else. A settled press is the case the screening exists to answer cheaply, and re-observing a surface the settle already proved quiet is the round trip --settle was built to remove. So the fix is the explicit signal, not the removal.

Two related points the note above left implicit, both now pinned by tests:

  • The trigger is wider than the guard. Any command that resolved a target through a captured tree records a baseline; only press/click are guarded, per the ADR 0011 targetStability cells. Narrowing the writer to press/click would un-guard the press that follows a fill which opened a keyboard.
  • A dispatch that failed after corroborating still records. It touched the device through a resolved tree, and withholding the tree retired the baseline a previous mutation had recorded, leaving the press after it unscreened.

@thymikee

Copy link
Copy Markdown
Member Author

The earlier findings from 3e64c33 are mostly fixed at 1626bc9: the selectors doc now names the gate's owner, the docblock states that the reason/hint pairing is a convention, and the import that broke the eager-closure check is gone. All 37 checks pass now. One finding from that review is still open, and I see no reply or change for it here or in #3548 at 40edc78.

movingTargetRectsStable and MOVING_TARGET_GATE_RECT_TOLERANCE_PX = 1 in moving-target-gate.ts copy the 4-field abs-compare against RECT_TOLERANCE_PX = 1 in rectsWithinTolerance (interaction-surface-signature.ts). The code comment cites that file as the source of the band. It explains why the gate skips kernel's 0.5 px areRectsApproximatelyEqual, but not why a second copy of the 1 px rule exists. If either band changes, the gate and the post-gesture no-effect check will disagree on the same rect pair. The rule to satisfy: one tolerance comparison decides whether two capture rects count as the same position. Please export it once from @agent-device/kernel/rect-center, next to normalizeRect and areRectsApproximatelyEqual. It should take a rect pair, normalize first, and use the 1 px band. Then call it from the callers of rectsWithinTolerance and from the gate, and delete both local constants and loops. This can land in #3548 if the stack keeps it there.

Before merge, the shared helper must land in this PR or in #3548. I could not answer whether the baseline comes from the tree the action resolved against. Nothing in this PR writes movingTargetBaseline, and the writer and the settle.diff rule are in #3539, which I did not read. When #3539 lands, please widen the MovingTargetBaseline.tree doc, which names only the pre-action tree, to cover the converged-settle case. I read the #3548 fixes through their commit messages, git show --stat, and a grep at 40edc78. I did not run their tests, and I ran no local tests here.

The open Cubic threads need a pass too. These still apply and are fixed in #3548 (the writer-less code is unreachable until #3539): measured set from bounds alone and no surfaceBundleId match. Three more P2 threads still apply: doc says rect still changing, test uses only notEqual, and selector quoting. The delayed-start animation thread needs no code change, but #3539's live table has no delayed-start case. Two threads are fixed at this head and can be resolved: owner named in selectors doc and reason/hint convention docblock.

@thymikee
thymikee added this pull request to stack #3569 October 13, 2026 08:37
@thymikee
thymikee force-pushed the feat/3471-moving-target-judgement branch from 1626bc9 to 3e6c29f Compare October 13, 2026 11:34
@thymikee

Copy link
Copy Markdown
Member Author

Gate evidence at this layer's head

pnpm check:affected --run --base origin/main on 3e6c29f44 — passed (exit 0, full run, not a summary-only run). pnpm format first, and the worktree was clean afterwards; the dirty-file capture taken at the end of the run was empty.

Rebased onto origin/main at a95d2d539. git range-diff against the previous head 1626bc9ec shows the same three commits with identical patches — the rebase moved the base only, and 1626bc9ec's own review commits are unchanged in content.

Reply to the review at 1626bc9

The one open finding, the duplicated tolerance rule, is fixed in #3548 at 3d8e3fff6, which is where you allowed it to land. areCapturedRectsAtSamePosition(a, b) is the 4-field abs-compare against 1 px, exported from @agent-device/kernel/rect-center beside normalizeRect and areRectsApproximatelyEqual; it normalizes first and owns CAPTURE_RECT_TOLERANCE_PX = 1. rectsWithinTolerance's five call sites call it directly and the gate calls it too, and MOVING_TARGET_GATE_RECT_TOLERANCE_PX, movingTargetRectsStable and both loops are deleted — neither name exists anywhere in the repo at these heads. So the gate and the post-gesture no-effect check cannot drift.

You asked whether the baseline comes from the tree the action resolved against, and said you could not answer it without #3539. It does, and #3539 says so: MovingTargetBaseline.tree is now documented as "the tree the action acted through: its resolution's own observation, or the settled one its --settle published as rest" (2c6a17794), which is the widening you asked for once a writer existed. The writer records preAction from the dispatch result, not session.snapshot, and a converged settle contributes only the tree it actually stored — the signal is settle.diff, so a settle that converged but stored nothing cannot supply a tree. #3539's body states the reasoning and the tests that pin it.

The seven threads you listed are each answered at their own thread with the commit that settles it, and all of them read resolved at these heads: the measured-from-bounds and missing-surface findings by #3548, the three P2s by #3548 as well, and the two fixed at the previous head are resolved. The delayed-start-animation thread needs no code change and is recorded as an open gap in #3539's live table, which is where a device run could close it.

Nothing here changed in content, so the L1 code you read at 1626bc9 is the code at 3e6c29f44.

thymikee added a commit that referenced this pull request Oct 13, 2026
… it (#3471)

Three places said less than the code does, all found in review:

- `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for
  this doc to be widened when the writer landed, because it cannot be read
  truthfully from that layer: the converged-settle case records the tree that
  settle published as rest, not the one the action started from.
- `markMovingTargetBaseline` listed "a raw coordinate" among the routes that
  leave the session idle. A point target with `--verify` or `--settle` DOES
  capture a tree in `resolvePointInteractionTarget`, so the idle list is the
  routes with no tree of their own, not the point-shaped ones.
- `DeferredSurface.stabilize.baselineSignature` still pointed at
  `markPostGestureStabilization`, which is `markSurfaceStabilization` now.

Also shrinks five comments that were narrating control flow rather than
stating the invariant: the field-owner claim, the retirement comment, the
carrier comment, and two guarantee cells. Line counts matter at this layer's
budget, and a claim in prose is worth less than the same claim in a name.
@thymikee

Copy link
Copy Markdown
Member Author

I re-reviewed #3538 at 3e6c29f. The earlier findings from 1626bc9 are addressed, and I found no new problems in the code.

The remaining gap is evidence. I did not read #3539's baseline writer or the MovingTargetBaseline.tree doc change in 2c6a177, so I have not confirmed that the baseline is the tree the action resolved against. I also ran no tests or mutations, so "each test fails when its guard is reverted" rests on your statement.

Not blocking, and fine to take or leave: stableCaptureSignalsEqual at stable-capture-signal.ts:9 still keeps its own 1px tolerance and rounds first. It asks the same question as #3548's areCapturedRectsAtSamePosition in rect-center.ts, so please route it through that function in #3548, or narrow the "one place" wording in the PR body.

Could MovingTargetBaseline stay in contracts beside CommandSessionRecord, with movingTargetGateDecision, the window and gap constants, and targetStillMovingHint moving into src/commands/interaction/runtime/moving-target-gate.ts? That is their only consumer, and it would remove the new @agent-device/contracts/moving-target-gate subpath and its export-snapshot entry.

Two of the older inline threads still apply: the baseline writer gap and the delayed-start animation. The first is fixed in #3548 at moving-target-gate.ts:254. The second needs no code change here, and #3539 records it as an open gap. Four lower-priority threads also still apply: the interaction-error doc, the bundle id check, the refNotFound test and the hint quoting. Their fixes live in #3548, and the code they cover cannot run until #3539 adds the writer. Two threads are fixed at this head and can be resolved: the interaction-error owner note and the reason/hint pairing doc.

I know of no conflicts. Smoke Tests and the Mutants shards are still queued or running, none have failed. The diff does overlap their routes (the selectors and interaction-settle shards, and tapCommand in Smoke). The gate cannot fire without the #3539 writer, so a smoke regression from this layer is unlikely.

Please wait for those checks, then merge #3538 together with #3548, which holds the tolerance fix, and #3539, which holds the writer, the guarantees cell and the live evidence. The duplicated tolerance rule clears only if the stack lands together.

@thymikee

Copy link
Copy Markdown
Member Author

At 3e6c29f this PR now conflicts with main. The code verdict and the evidence gap from the last review are unchanged. Please rebase onto main; the next review will check the conflict resolution together with the requested evidence.

…3471)

A press resolves a point from one capture, and nothing between that capture and
the dispatch looked at the surface twice. A press issued right after an action
that animated the UI can therefore resolve a point the control has already
vacated and report success on the vacancy — measured on ADB Android, whose
helper reports frames in flight.

This adds the judgement: a pure window/stability decision in
@agent-device/contracts, the same-node identity lookup it screens with in
@agent-device/ad-script, and the runtime seam that spends the window in front of
the tap, re-resolving once and tapping where the target came to rest. The
session does not yet record the history the gate reads, so every press still
answers `idle` and pays nothing.
…dapters read (#3471)

The eager-closure gate (ADR 0027, `scripts/__tests__/eager-closure-budgets.test.ts`)
holds every façade that reads an interaction reason to "no more modules than the
merge-base evaluated". Importing `TARGET_STILL_MOVING_REASON` from
`@agent-device/contracts/moving-target-gate` pulled that module and its kernel
dependencies into six closures, `packages/maestro/src/daemon-port/daemon-runtime-port.ts`
and `packages/replay-port/src/daemon-port/session-replay-maestro-runtime.ts` among
them. The reason is a literal in `INTERACTION_ERROR_REASONS` again, the one
vocabulary every adapter already reads, so the leaf imports nothing; the hint
stays in contracts, which is on no façade path.
@thymikee
thymikee force-pushed the feat/3471-moving-target-judgement branch from 3e6c29f to c4d517f Compare October 13, 2026 16:21
thymikee added a commit that referenced this pull request Oct 13, 2026
… it (#3471)

Three places said less than the code does, all found in review:

- `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for
  this doc to be widened when the writer landed, because it cannot be read
  truthfully from that layer: the converged-settle case records the tree that
  settle published as rest, not the one the action started from.
- `markMovingTargetBaseline` listed "a raw coordinate" among the routes that
  leave the session idle. A point target with `--verify` or `--settle` DOES
  capture a tree in `resolvePointInteractionTarget`, so the idle list is the
  routes with no tree of their own, not the point-shaped ones.
- `DeferredSurface.stabilize.baselineSignature` still pointed at
  `markPostGestureStabilization`, which is `markSurfaceStabilization` now.

Also shrinks five comments that were narrating control flow rather than
stating the invariant: the field-owner claim, the retirement comment, the
carrier comment, and two guarantee cells. Line counts matter at this layer's
budget, and a claim in prose is worth less than the same claim in a name.
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto main; gate evidence and replies at c4d517f60

Rebased onto origin/main at f70224ad8 (which brought #3495, #3515, #3533, #3547, #3572 and the vitest 5 upgrade). One file conflicted — packages/selectors/src/interaction-error.ts, where #3495 added the covering-node vocabulary at the same import site this PR's reason literal sits. Both sides are kept: the new SnapshotCoveringNode import and coveredInteractionDetails from main, the no-import rule and the literal target_still_moving from here. git range-diff against the head you reviewed (3e6c29f) shows the second commit unchanged and the other two differing only in that context line.

Size unchanged: 12 files, 949 insertions, 9 deletions — 958 gross. pnpm install --frozen-lockfile, pnpm build, pnpm typecheck clean on the new base. pnpm check:affected --run --base origin/main at c4d517f60 — passed (exit 0), with pnpm format first and a clean worktree afterwards.

One honest note on that gate run: the first attempt exited 1 on four tests — interaction-response-shape.contract (press @ref envelope) and three scripts/fuzz/harness cases — every one of them "Test timed out in 5000ms", none an assertion. Two other agents' full suites were running inside that window on this host. Both files pass in isolation at this head (10/10 and 13/13), and the slot re-run green end to end. No file in this PR touches either route.

The third copy of the 1 px band (stableCaptureSignalsEqual, which you flagged as take-or-leave) is fixed rather than re-worded, in #3548. That file kept a local GEOMETRY_TOLERANCE_PX = 1 with its own 4-field loop; it now calls areCapturedRectsAtSamePosition too, and the local constant and loop are deleted, so the sentence in #3548's body holds without qualification: one rule decides whether two captured rects are one position, for the moving-target gate, the post-gesture no-effect check, and the settle quiet window.

What the fix had to keep: that loop rounded each field before comparing, and the rounding was load-bearing rather than cosmetic. A rect quantized to the pixel can sit 1.9 px apart raw and still be one pixel apart in the unit the band is measured in, so comparing raw geometry against 1 px would restart the quiet window on pairs this loop has always called at rest — a device-facing timing change with no device available to verify it. So the quantization moved to the one place the signal mints a rect, and the comparison is now the shared rule over integers, which is exactly the old behaviour. It is stated as a test, not as a claim: 0.51 against 2.4 is rest, 0.51 against 3.4 is not, and reverting the quantization reddens that test.

On moving the pure judgement out of @agent-device/contracts/moving-target-gate. I am not arguing the placement is better — your version removes a public subpath and its export-snapshot entry, and MovingTargetBaseline beside CommandSessionRecord is the more honest home, since that record is the only reason the type crosses a boundary at all. I am declining to do it inside this stack, for two reasons I would rather state than quietly ignore:

  1. The type is created in this layer and read by name in the two layers above it, so the move is not a two-file edit: it rewrites all 19 commits of a stack you have already reviewed twice, and re-opens the CI and live-evidence trail that each of those commits carries. A pure relocation should not do that.
  2. The repository's own rule says a relocation like this belongs in its own PR: docs/agents/pull-requests.md exempts rename-only moves titled refactor(move) from the 1,000-line budget when git diff -M90% proves no material content change. This stack has no line room for it (fix(daemon): let a finishing mutation record the tree it acted through (#3471) #3539 sits at exactly 1,000), and the exemption exists precisely so a move is not smuggled inside a behaviour change.

So: say the word and I will land it as a move-only follow-up on top of this stack, with the subpath, the snapshot entry and the export map entry gone and the tests moved with their module. What I did not want to do is pay for it by re-writing history under an approved review.

The threads you listed as still applying are all replied to at their own thread with the fixing commit, and all of them now read resolved on all three PRs (I checked reviewThreads.isResolved rather than assuming): the measured-from-bounds and missing-surface findings, the three P2s, and the two fixed at the previous head. The delayed-start-animation thread stays open in substance as a recorded gap on #3539, where a device run is the only thing that can close it.

Merging: not authorized in this pass, so nothing is merged. All three heads are green and mergeable, and as you note the duplicated-tolerance finding clears only if the three land together — #3538 and #3548 now carry all three copies of the band, and #3539 carries the writer that makes any of it reachable.

@thymikee

Copy link
Copy Markdown
Member Author

The PR is ready at c4d517f. The merge conflict from the earlier review (3e6c29f) is gone, and I see no conflicts now.

Not blocking: the docblock in packages/selectors/src/interaction-error.ts (https://github.com/callstack/agent-device/blob/c4d517f/packages/selectors/src/interaction-error.ts#L6) says the module "imports nothing", but line 1 now has the import type from @agent-device/kernel/snapshot added by #3495. It is erased at runtime, so the eager-closure point still holds. Saying "has no runtime imports" would be more exact. You can take this or leave it.

I ran no tests, typecheck or mutation runs, so your check:affected pass at c4d517f is not verified by me. Smoke Tests and the Mutants shards (kernel-errors, interaction-settle, selectors, target-annotation-serde, snapshot-occlusion) are still queued or running, and none has failed. The selectors and interaction-settle shards do exercise this diff. The gate cannot fire until the #3539 writer lands, so a Smoke regression from this layer is unlikely. I did not read the #3548 or #3539 heads, and I did not re-check how the gate handles optional refs on find and web nodes, since the gate is dormant here.

The cubic-dev-ai threads are already settled, so please resolve them: the P1 on measured-from-bounds (#3538 (comment)) is fixed in #3548 and the code cannot run until #3539 writes the baseline. The P1 on delayed-start animation (#3538 (comment)) needs no code change and is recorded as an open gap in #3539's live table. The P2 on the still-changing rect doc (#3538 (comment)), the P2 on Surface scope in targetShownAtRest (#3538 (comment)), the P2 on the generic rejection assertion (#3538 (comment)) and the P2 on apostrophe quoting in the hint (#3538 (comment)) are all fixed in #3548 and dormant at this head. The two P3 threads (#3538 (comment) and #3538 (comment)) are fixed at this head.

Once Smoke and the Mutants shards finish green, merge this together with #3548 (tolerance and thread fixes) and #3539 (baseline writer and live evidence).

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 13, 2026
@thymikee
thymikee merged commit da29d7f into main Oct 13, 2026
36 checks passed
@thymikee
thymikee deleted the feat/3471-moving-target-judgement branch October 13, 2026 17:25
thymikee added a commit that referenced this pull request Oct 13, 2026
#3471) (#3539)

* feat(daemon): let a finishing mutation record the tree it acted through (#3471)

The history the gate reads. The session's deferred surface becomes a two-variant
claim — `stabilize` (the capture side still owes a quiet-window loop) or
`recheck` (a mutation dispatched through a captured tree whose own effect may
still be animating) — and a mutation that resolved a target records that tree at
the instant it finished, so the next press can ask whether its own coordinates
are still in play. ADR 0014's device side-effect seam retires the record, and a
press never erases a stabilization the capture side still owes.

The `targetStability` matrix cells, their Layer-3 scenarios on both runtime
paths, and the caller-facing help/docs land with it: the refusal is observable
from here.

Closes #3471.

* chore(gates): name the deferred-surface owner the recheck variant shares (#3471)

* fix(daemon): record only the tree a mutation actually acted through (#3471)

`chooseMovingTargetTree` preferred the session snapshot whenever its node array
differed from the tree the action resolved against, reasoning that a `--settle`
or `--verify` capture must have published something newer. Array identity cannot
establish that: a freshness capture and a `@ref` press's authorized frame reach
`session.snapshot` the same way, so the marker could record a surface this action
never looked at — the exact substitution its own docblock forbids, and a
`stationary` verdict made against the wrong tree. The baseline is now the
acted-through tree, always. A settled press simply owes its successor one
re-observation instead of being answered from a tree of unknown provenance.

The corroborated-failure path also dropped the tree. That sibling call reached
the same finalizer without `preActionTree`, so a corroborated iOS tap retired the
baseline a previous mutation had recorded and left the next press unscreened — a
dispatch that touched the device behaving like one that never looked at a tree.
It now passes the resolved target's own tree.

Help and website docs now scope the guarantee to what the gate actually covers:
ref and selector targets on the runtime path, dispatched (not "tapped") at the
resting position, `movedToRect` optional, and a raw-coordinate press explicitly
outside it.

Refusing to narrow the WRITER to press/click: the ADR 0011 cells scope which
commands the gate guards, while this marker asks whether a mutation just left an
animating surface, and a `fill` that opened a keyboard or a `longpress` that
started a drag answers that the same way a press does. The breadth is now pinned
by a test rather than left implicit.

* test(daemon): bracket the recorded baseline instant with the reads around it (#3471)

The bound was "within the last second" of assertion time, so a CI pause between the mark and the
comparison could fail the test while the marker itself stayed correct. Reading the clock before the
call and again in the assertion keeps the claim the test actually makes — the stamp is the mutation
END, not the action's start — and removes the slack the bound needed.

* docs(website): name the two dispatch routes the moving-target check does not cover (#3471)

The paragraph claimed the check runs for any @ref or selector target, which held for neither
exclusion. `click @ref` on a backend that dispatches to the element itself returns from
`maybeTapRefTarget` before the guard is reached, and the Maestro non-hittable coordinate fallback
is a fused runner request that never resolves a point at all. Both are now stated as the exceptions
they are, and "nothing was tapped" becomes "nothing was dispatched" for the same reason the command
metadata changed verb.

* fix(daemon): let a settle that proved rest record the tree that proved it (#3471)

Removing the session-snapshot preference took the baseline back to the tree the action started
from, and that broke a contract the repository already encodes: a press following a converged
`--settle` spends a tap and nothing else (test/integration/provider-scenarios/settle-observation.
test.ts asserts the runner transcript directly). A settled press is the case the screening exists
to answer cheaply — re-observing a surface the settle already proved quiet is the round trip
`--settle` was built to remove.

The provenance problem the review found stays fixed. Instead of inferring "newer" from a differing
node array, the finalizer forwards the action's own settle observation and the marker reads the one
field that carries the fact: `diff`, which the runtime attaches only when the loop converged AND
stored its settled tree as the session snapshot within one surface. `settled` alone is not that
fact — a sparse capture is refused by the store and a cross-surface transition withholds the diff,
and in both cases the session's newest tree belongs to some earlier capture. Everything that is not
a stored settled tree keeps the acted-through tree: a timed-out loop, a lone `--verify` capture, a
freshness capture, a `@ref` frame.

* refactor(daemon): hand the finalizer its optional values directly (#3471)

Both forwards were conditional property spreads, and the complexity ceiling counts each one as a
branch on the already-branching dispatch path — `check:fallow` flagged the function at threshold on
the previous commit. The finalizer's parameters are optional-with-undefined, so passing the value
directly states the same fact with two fewer branches: an absent settle or an unresolved target
forwards undefined, which is what the marker already treats as "no such evidence".

* docs(website): say the motion check could not confirm rest, not that the target moves (#3471)

The refusal covers two outcomes and the paragraph described only one. A re-check that never measured
a position refuses for the same reason a target that kept moving does, and a reader told the target
"was still moving" reads missing evidence as measured motion — then waits for motion that the gate
never observed. Same wording the typed reason's own docblock already uses.

* fix(interaction): report a refused press as an unconfirmed position, not as measured motion (#3471)

A `target_still_moving` refusal covers two outcomes and all three of its surfaces described only
one. The gate also refuses when a re-observation never measured a position at all — the `@ref` case
where reconciliation kept the authorized frame node — so "the target was still moving" reported an
observation the gate had not made, in the message, in the published hint, and in the website
paragraph. A caller told the target is moving waits for movement; the actual recovery there is to
re-resolve the target.

All three now state the confirmation that failed. The reason literal is untouched: `reason` is the
branch point, and the prose around it is not allowed to be more specific than the code that raised
it. The unmeasured `@ref` refusal asserts both halves of that — the new wording present, the motion
claim absent — so the text cannot drift back into a claim this path cannot support.

* refactor(daemon): cut the duplicate settle-branch test and the repeated baseline reasoning (#3471)

* docs(daemon): put the baseline's real shape in the docs that describe it (#3471)

Three places said less than the code does, all found in review:

- `MovingTargetBaseline.tree` named only the pre-action tree. #3538 asked for
  this doc to be widened when the writer landed, because it cannot be read
  truthfully from that layer: the converged-settle case records the tree that
  settle published as rest, not the one the action started from.
- `markMovingTargetBaseline` listed "a raw coordinate" among the routes that
  leave the session idle. A point target with `--verify` or `--settle` DOES
  capture a tree in `resolvePointInteractionTarget`, so the idle list is the
  routes with no tree of their own, not the point-shaped ones.
- `DeferredSurface.stabilize.baselineSignature` still pointed at
  `markPostGestureStabilization`, which is `markSurfaceStabilization` now.

Also shrinks five comments that were narrating control flow rather than
stating the invariant: the field-owner claim, the retirement comment, the
carrier comment, and two guarantee cells. Line counts matter at this layer's
budget, and a claim in prose is worth less than the same claim in a name.

* test(interaction): name the wall-clock window the daemon-route scenario trusts (#3471)

The press-to-press scenario goes through the real daemon route, and production
installs no runtime clock on that route, so the gate's 500 ms window runs on the
wall clock: press B has to reach its gate read within 500 ms of press A
finishing. A host loaded enough to starve the process past that sends B idle,
and B spends the tap — the same `Unexpected provider call: ios.runner.tap` a
wiring regression gives, which is exactly the failure this scenario exists to
catch.

The alternative was threading a `clock` through the router deps so the daemon
composition could install one, for a guarantee the scenario does not own; the
harness-level `createContractClock` only reaches the in-process runtime, which
this scenario deliberately does not use. So the risk is named where it can be
read instead of paid for.
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-13 17:59 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.

1 participant