Skip to content

fix(is): report ambiguous selector matches as ambiguity, not absence (#2870) - #3340

Merged
thymikee merged 8 commits into
mainfrom
fix/is-ambiguous-selector-2870
Oct 9, 2026
Merged

thymikee merged 8 commits into
mainfrom
fix/is-ambiguous-selector-2870

Conversation

@thymikee

@thymikee thymikee commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #2870. Two coupled fixes for is on a selector that matches more than one node.

  1. Ambiguity is no longer reported as absence. The strict reads (is predicates other than exists/absent, and get attrs — both readUnique rows) turned a multi-node match into COMMAND_FAILED/selector_not_found with an empty candidate list — which to an agent reads as "the element does not exist" on a screen where it is plainly on display. A new shared door keys on the pipeline outcome kind, never message text: none stays selector_not_found; ambiguous now answers with the existing AMBIGUOUS_MATCH code plus matches and up to five formatSnapshotLine candidates — ADR 0011's bounded-disclosure shape and the acting path's exact shape (selector-action-resolution.ts), so no new machine vocabulary is introduced and CLI/MCP/replay render candidates through the one existing reader. screenshot --crop-on already had its own typed refusal (CROP_TARGET_AMBIGUOUS) and keeps it. Out of scope by declared policy: get text, is exists, wait.

  2. RN text reads as one node. React Native reports one authored <Text> twice on a regular capture (paragraph view + accessibility-element mirror; measured live via snapshot --raw: identical label, byte-identical rects, both hittable: true). resolveElementReportedTwice extends the read door's EXISTING structural collapse rule (wait still refuses an unverified-hittability wrapper chain (native runner path) #2498 wrapper control: one ancestor-descendant chain) with a text-echo branch keyed on label identity + wrapper-slack rects + no semantic touch target, keeping the outer reporter — the node whose testID the app authored and the row snapshot -i publishes. Nothing in snapshot presentation changed: the client-serialization dedup invariant stays untouched, refs and find list/snapshot --json counts are unchanged; only fail-closed rows collapse. The replay gate consumes the same function, so live and replay resolve identically.

Validation

  • pnpm check:affected --run (one run per pushed head, recorded in comments): all checks pass at head c6acd1e5e (1,441 unit files, layering, eager-closure ratchet, fallow, wire-compat, command-docs).
  • Live on iPhone Duo / iOS 27.1 at head e04e85be2: is visible|text 'label="Catalog scroll: top"' pass; is visible 'role=text' → AMBIGUOUS_MATCH matches=22 + 5 candidates + +17 more, no reason field; a true miss → selector_not_found with no matches/candidates. Not re-run at the issuance heads — the daemon test below is the verification for those; a live Catalog re-run at this head is left to a human.
  • Reviewer-pinned sequence as a daemon test (selector-runtime-ambiguity-issuance.test.ts): snapshot -i frame → ambiguous is → press <printed candidate ref> acts on the listed node's point, not the earlier tree's positional twin; the unpinned body is refused, never retargeted; the sparse-capture twin prints no candidates and issues nothing. Unit door pins stored / retired-ref / sessionless / no-generation / unstored-capture branches separately.
  • Tests pair each positive with its closest negative: collapse vs. distinct subtrees vs. offset rect (>1 pt stays ambiguous, at both pipeline and command surface); ambiguity carries matches: 2; zero matches carries no count and no candidates.

Known risk

test/integration/android-emulator-e2e/live-assertions.ts accepts only selector_not_found/predicate_failed details reasons, so an ambiguous Android selector now fails loudly instead of scrolling. Lane unrun here.

…RN text pair at the read door

Two coupled fixes for #2870.

1. The uniqueness rows (is <predicate> except exists/absent, and get attrs)
   reported a multi-node match as selector_not_found. To an agent that reads
   as proof the element does not exist, on a screen where it is plainly on
   display. The shared door now keys on the pipeline outcome kind: none stays
   selector_not_found, ambiguous becomes AMBIGUOUS_MATCH with the new typed
   reason selector_ambiguous, details.matches, and bounded candidate snapshot
   lines shaped like the acting rows' refusal.

2. React Native reports one authored <Text> twice on a regular capture: the
   paragraph view plus its accessibility-element mirror, identical label and
   rect, both carrying a hittability fact. Label selectors on RN text
   therefore always matched two nodes and every fail-closed read refused.
   resolveElementReportedTwice recognises that pair at the shared selectors
   resolution door and keeps the OUTER reporter — the node whose testID the
   app authored and the row snapshot -i already publishes, because
   collectIosRepeatedStaticSuppression has always suppressed the mirror in
   the interactive projection. The replay verification gate consumes the same
   rule, so live and replay resolve identically. find list and snapshot
   --json still show both nodes; only rows that refuse to choose collapse.
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-09 06:59 UTC

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.13 MB 5.13 MB +1.4 kB
Package (unpacked) 5.13 MB 5.13 MB +1.4 kB
Package (download) 1.54 MB 1.54 MB +561 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 19.4 ms 19.5 ms +0.1 ms
CLI --help 54.4 ms 56.2 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 13 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/selector-observation-failure.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
Comment thread packages/selectors/src/interaction-targeting.ts
Comment thread src/commands/schema/cli-help.ts Outdated
Comment thread src/commands/interaction/runtime/selector-observation-failure.ts Outdated
Comment thread src/commands/interaction/runtime/selector-observation-failure.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
… a new reason

Review correction: #2870's read-path refusal should add no new machine
vocabulary. The code union (packages/kernel/src/errors.ts) already owns
AMBIGUOUS_MATCH with the exact semantics, and CLI/MCP/replay render its
candidates and the acting path's partial-refs publication key on that code.
The ambiguity outcome now carries code + matches + bounded candidates only;
the selectorAmbiguous reason is removed from INTERACTION_ERROR_REASONS.

Also: closest-negative commands surface asserts no matches/candidates leak
into the selector_not_found outcome, and the offset-rect negative is proven
at the command surface, not only the pipeline.

@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 7 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

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

Comment thread website/docs/docs/replay-e2e.md Outdated
- Eager-closure budget: the daemon entry gained one static edge for the new
  failure-door module. The gate names its own fix -- give the code a home in a
  module the closure already evaluates -- so the builders move into
  selector-read-shared.ts (the strict reads' existing shared-failure home) and
  the standalone module is deleted. No baseline touched, no lazy import bolted
  onto an error path.
- Contract fields are now written AFTER caller details, so no caller spread
  order can clobber them; both strict rows prove they report the matched
  alternative as details.selector for the same capture.
- The ambiguity hint no longer echoes the selector into a single-quoted
  find command (label="It's here" broke the outer quoting).
- The text-echo collapse gains the reportage clause: every non-reporter
  candidate must be the accessibility element the platform reports for the
  reporter (isReportedAccessibilityElement). Same label + same frame with an
  authored (view-backed) descendant stays ambiguous -- the closest-negative
  fixture pins exactly that. The offset-rect fixture gains the live RN roles
  so the rect is the only differing fact.
- The 5-candidate cap moves to kernel/errors as ELEMENT_MATCH_CANDIDATE_LIMIT
  beside the ElementMatchCandidateDetails type the surfaces read; all three
  producers (acting refusal, find refusal, read door) consume it. The shared-
  shape comment now names what is actually shared.
- Help/docs: 'Uniqueness-based reads' replaces internal 'nominating reads';
  ref guidance scoped to ref-taking commands (is rejects refs); the RN pair
  bullet qualified as the observed iOS accessibility shape.
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Review pass addressed on e04e85be2.

Failing check fixed at the invariant. eager-closure-budgets flagged the new door module as a new static edge on src/daemon.ts (652 vs 651). Took the gate's first option: the builders moved into selector-read-shared.ts — the module both strict-read rows already load for their other shared failures — and the standalone module is deleted. No dynamic import on an error path, no baseline touched. The test passes locally on this head and pnpm check:affected --run was re-run once on e04e85be2: all checks pass.

Cubic findings: all seven addressed — replies on each thread. The two structural ones: (a) the text-echo collapse now additionally requires every non-reporter candidate to carry the reported-accessibility-element role/subrole (isReportedAccessibilityElement), with RN_TEXT_ECHO_AUTHORED_CHILD_NODES pinning same-label/same-frame/authored-descendant as the closest negative — geometry cannot distinguish mirror from second authored element, so the rule no longer claims to and refuses without the reportage; (b) the 5-candidate cap is now ELEMENT_MATCH_CANDIDATE_LIMIT in kernel/errors beside ElementMatchCandidateDetails, consumed by all three producers.

replay-e2e.md boundary, stated for the reviewer: an is step that ends ambiguous surfaces as REPLAY_DIVERGENCE with divergence.cause.code = AMBIGUOUS_MATCH (session-replay-divergence.ts copies error.code into the cause verbatim). The doc's error.details.reason: selector_not_found sentence is about the never-appears wait for acting steps and was already not an exhaustive list of live failure modes; the new bullet states the ambiguity surface explicitly instead of rewriting that paragraph.

…ach code answers

Pre-dispatch target binding can stop an annotated step before the command
ever runs (identity set >1 with no isolating signal, or no capturable fresh
snapshot), and both guards answer IDENTITY_UNVERIFIABLE, not AMBIGUOUS_MATCH.
Scope the dispatched-ambiguity claim accordingly rather than conflating the
two codes -- ADR 0012 retired exactly that conflation.

@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 13 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 src/commands/interaction/runtime/selector-read-shared.ts Outdated
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

New docs finding fixed on 8341e3690 (replied on comment 4224198593 with the independently verified evidence). The paragraph now scopes AMBIGUOUS_MATCH to the dispatched-ambiguity outcome and names the pre-dispatch stop (IDENTITY_UNVERIFIABLE for identity set >1 with no isolating signal, or no capturable fresh snapshot), closing with what each code proves. Verification still does not emit AMBIGUOUS_MATCH — different questions, per ADR 0012.

Four owed confirmations, current head 8341e3690:

  1. Eager-closure went green because the graph got thinner, not a baseline move. git diff --stat origin/main...HEAD -- scripts/__tests__/ is empty across the whole branch — neither the gate nor the committed merge-base closure artifact was ever touched. The fix deleted selector-observation-failure.ts (zero references remain in the tree) and moved the builders into selector-read-shared.ts, a module the daemon closure already evaluated, so the entry's evaluated-module set returned to the merge-base's 651. The gate recomputes the live graph on every run; it passes at 8341e3690 on that recomputation.
  2. The mirror gate has a negative fixture. RN_TEXT_ECHO_AUTHORED_CHILD_NODES: same label, same rect, same ancestry chain, descendant authored (view-backed RCTParagraphComponentView/UIView) instead of the platform's reported accessibility element → asserts ambiguous through both readUnique and cropTarget in selector-pipeline.test.ts. The positive mirror-pair test still resolves in the same file, and the offset-rect fixture gained the live RN roles so a rect delta is its only differing fact.
  3. is and get attrs agree on details.selector. Contract fields are written after ...details in both builders, and the test both strict rows report the matched alternative as details.selector, not the authored expression drives selector label="Nowhere" || label="Save" through both commands on the same capture and asserts details.selector === 'label="Save"' for each.
  4. Gate re-run on the head I pushed: pnpm check:affected --run once on 8341e3690 — all runnable checks passed.

Process note acknowledged: every cubic root thread on this PR now has a non-bot reply from me (verified via the thread API — 8/8), including where a finding led to a design change. Nothing merged or approved.

@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Smoke red triaged as #3342's flake class — job 113543477378, step "Preflight iOS runner through public CLI", typed details.kind: daemon_startup_failed (startupTimeoutMs: 15000, startupAttempts: 1) on run 37844978745 (head 810b923a8). No design change in response; the failed job re-run with --failed, and 8341e3690's fresh CI is in flight anyway.

Independent confirmation the lane cost isn't my diff:

  • The ratchet failure counted +1 module (652 vs 651), not +6 — all of the module's imports were already inside the daemon closure, so it could not have been the startup cost; 3.5 kB against a 652-module closure is noise at a 15 s timeout. At current head the module doesn't exist at all — the builders live in an already-evaluated module, so the closure is back at the merge-base set.
  • Whole-branch file list touches zero daemon bootstrap/spawn/registration paths (git diff --name-only origin/main...HEAD | grep -cE 'daemon-runtime|bootstrap|daemon-startup|daemon-command-registry|request-router|server/' → 0). The only daemon-side files touched are selector-match-errors.ts (find refusal body, called per-refusal) and the replay classification gate.

Still owed and unchanged at 8341e3690: the eager-closure green came from the thinner graph with no baseline move, RN_TEXT_ECHO_AUTHORED_CHILD_NODES is the mirror-gate negative fixture, is and get attrs agree on details.selector by test, and pnpm check:affected --run was recorded once on this pushed head.

…and read routes

The acting route (selector-readiness.ts) and the strict reads (this branch's
new read door) built the same four things -- COMMAND_FAILED,
formatSelectorFailure, the typed reason, selectorFailureHint -- from two
modules, so a hint or message edit in one would silently drift from the
other. One builder now lives in selector-read-shared.ts, the module the
acting chain already reaches through resolution.ts and the reads already
import -- no new module, no new static edge (eager-closure budgets: 774/774
green). The two axes the routes genuinely differ on stay explicit
parameters: unique (message shape) and dispatched -- the acting row proves
'no' via discloseDispatch, the read route proves nothing about reaching the
device and omits the field rather than asserting one. Both halves are
pinned: selector-readiness.test asserts dispatched 'no' on the one-attempt
press refusal, selector-read-policy asserts its absence on the read
refusal, so a silent add or drop on either side fails locally.
@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Cubic P2 on the duplicated selector_not_found builders addressed on 973082509 (replied on the thread with the reasoning). One builder in selector-read-shared.ts; dispatched and unique stay explicit parameters — acting row passes 'no', read row proves nothing and omits the field; both halves pinned by tests (selector-readiness.test.ts + the existing press-target-readiness.test.ts:269 integration pin assert dispatched: 'no' survives; selector-read-policy.test.ts asserts the read refusal gains none). Full eager-closure-budgets locally: 774/774. pnpm check:affected --run once on 973082509: all checks passed.

One correction to the coordinator's ledger: the replay-e2e IDENTITY_UNVERIFIABLE scoping is not owed — it shipped in 8341e3690 (previous push, commit 'docs(replay): split the ambiguous-read divergence by which question each code answers', replay-e2e.md:106-112). It was listed as outstanding against e04e85be2, which predates it.

@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

The ambiguity fix looks right, but one problem needs to change before merge. At 9730825, is and get attrs print candidate @refs that were never issued on a ref frame. selectorAmbiguousFailure takes them from the internal full-tree capture. That capture issues no ref frame under ADR 0014. The acting path publishes its candidates through interaction-ambiguity-publication.ts, markSessionPartialRefsIssued and refsGeneration. find does the same at selector-runtime.ts:94. The read door does neither, and dispatchIsViaRuntime and dispatchGetViaRuntime do not publish on error.

After the usual snapshot -i, the frame is complete and numbered over the interactive tree. A plain press @e12 copied from an is candidate is then admitted by admitRefMutation and resolves against that earlier tree, where @e12 can be a different node. get attrs @e12 can read the wrong node the same way. commands.md tells callers to act on a listed candidate, so this can tap or read a different element than the one listed. That is worse than the misleading not-found this PR fixes. The rule is that every response that prints a candidate @ref must issue those refs on the frame it came from. Please route the read-row AMBIGUOUS_MATCH through publishInteractionAmbiguityCandidates in the daemon's is and get dispatch, or print no refs and drop the act-on-candidate advice. A daemon-level test should run snapshot -i, then an ambiguous is, then press <candidate ref>. It should land on the listed node or be refused with ref_not_issued.

I read ref-frame.ts, runtime-session.ts and selector-runtime.ts for this. I did not run a daemon test to confirm the wrong-node binding, since it depends on how refs are numbered in the interactive and full captures.

For live validation, the existing iOS run in the PR body was at e04e85b, not 9730825, and I did not reproduce it. Please run the RN Catalog screen at the fixed head. is visible 'label="Catalog scroll: top"' should pass. After snapshot -i, an ambiguous is visible 'role=text' should return AMBIGUOUS_MATCH with candidates. Then press <first candidate ref> should show the listed candidate's label in the resolved-target output, or be refused with ref_not_issued. It must not act on a different element.

Not blocking, and fine to take or leave: the .slice(0, ELEMENT_MATCH_CANDIDATE_LIMIT).map(formatSnapshotLine) rendering now exists in selector-action-resolution.ts:50, selector-match-errors.ts:29 and here, so one elementMatchCandidateDetails(nodes) helper beside formatSnapshotLine could serve all three. The RN-text bullet in commands.md names only is text and get attrs, but screenshot --crop-on and replay identity binding also collapse the pair. scrollToVisibleSelector in live-assertions.ts:42 accepts only selector_not_found and predicate_failed, so an ambiguous is visible now fails the helper instead of scrolling. Treating that as fatal is probably right, or the Android lane could be run once, and I did not run it. The floating doc block at selector-read-shared.ts:179 is attached to no symbol and could fold into observationReadFailure's doc without the budget narration.

Could the RN-pair rule live in snapshot presentation instead, so a label selector is unique for every row? The author chose to keep refs and counts stable, which seems reasonable. Could the read door also reuse publishInteractionAmbiguityCandidates or emit no refs, rather than gain a new publication path?

The cubic-dev-ai threads are fixed at this head and can be resolved: the find '<sel>' hint (#3340 (comment)), is takes no ref (#3340 (comment)), the reported-element check (#3340 (comment)), the help wording (#3340 (comment)), the shared-doc list (#3340 (comment)), the field order (#3340 (comment)), the iOS qualifier (#3340 (comment)), the replay-e2e wording (#3340 (comment)), and the readiness builder (#3340 (comment)).

All 21 checks pass. The Android live lane that uses the changed is reason is not among them. There are no conflicts. Before merge, the candidate refs from is and get attrs need to be published or dropped, and the act-on-candidate route needs the live run above.

is/get attrs printed candidate @refs minted from their internal full-tree
capture without issuing them, while the acting refusal publishes the same
shape through markSessionPartialRefsIssued + refsGeneration. After the usual
snapshot -i the authorized frame is the interactive tree, so a press of a
printed candidate was admitted and resolved against an EARLIER tree where the
same ref body names a different node -- the docs actively route callers there.
That is a wrong-node bind, worse than the flattened not-found this PR fixes.

ADR 0014's issuance rule for ambiguity refusals now has one implementation
beside the partial-frame primitive it wraps (the issueSettleRefs precedent):
publishAmbiguousMatchCandidateRefs issues the candidate bodies as a PARTIAL
frame over the capture the failing request just consumed and returns the
frozen epoch as refsGeneration. The acting touch runtime consumes it (its
interaction-ambiguity-publication module folded into the rule) and the is/get
dispatches run it through toDaemonResponse's issuance parameter, which the
routes that never print candidates (wait, read-only find) leave unset. Both
surfaces already pin printed candidates from refsGeneration, so the retry
resolves against the tree that listed it; the plain body still requires a
complete frame and is refused with the suggested pinned form.

Pinned by the reviewer's sequence as a test: snapshot -i frame -> ambiguous is
-> press <candidate ref> acts on the listed node's point, not the earlier
tree's positional twin; and the unpinned body is refused, never retargeted.

Also takes the reviewer's non-blocking items: elementMatchCandidateDetails
owns the cap + snapshot-line rendering the three AMBIGUOUS_MATCH producers
shared as copies, the floating doc block folds into observationReadFailure,
and the RN-pair doc bullet names every collapsing row (crop-on, replay
identity binding), not only the two reads.

@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 (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 src/daemon/session-snapshot.ts Outdated
Comment thread src/daemon/__tests__/session-snapshot.test.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
The eager-closure ratchet failed at the issuance head: importing
ELEMENT_MATCH_CANDIDATE_LIMIT as a value made snapshot-lines.ts (and
through it snapshot-diff.ts) evaluate kernel/errors.ts and its redaction
chain from the daemon's eager rows, for one constant used at one call
site inside the builder the three AMBIGUOUS_MATCH producers already
share. The cap, the slice, the renderer, and the pairing are one
contract, so the declaration belongs in the module that owns that
contract; surfaces compute "+N more" from matches - candidates.length
and never needed the constant. Kernel keeps the detail type; the
builder's docblock states the laziness rationale (ADR 0019).
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

check:affected recorded at ded07c0e5: one full pnpm check:affected --run, all runnable checks passed (the prior head 23bc27d09 red-lit the eager-closure ratchet locally — the shared disclosure builder pulled kernel/errors into the daemon's eager rows through one value import of the candidate cap; fixed by placement in ded07c0e5, no baseline touched).

…(ADR 0014)

The review's P1: updateSessionSnapshot deliberately skips storing a
sparse-quality capture, so candidates minted from that tree were issued
against the PREVIOUS stored tree's generation -- the same wrong-node
bind the issuance rule exists to prevent, arriving through the sparse
path. issueSettleRefs already carries the opposite ruling beside the
same primitive: what was not stored issues nothing. The second
implementation of the rule had dropped that guard.

Printing and issuing become one decision at the rule, keyed on the
typed fact the surfaces already read: the candidate bodies may only be
authorized when the capture the failing request consumed IS the tree
the session stores under the generation being frozen (node identity --
the command layer stores an annotation copy of the same array). A
sparse/unstored capture, a retired or sessionless ref, or a session
with no generation now degrades to the count-only refusal: truthful
matches count, no candidate refs, and a hint that cannot advertise an
affordance the frame could not honor. An advertised-but-unusable ref is
worse than no list.

Both consumer seams hand the rule the capture their request consumed:
the selector routes pass the capture runtime's consumedSnapshot slot
through toDaemonResponse's issuance parameter, and the touch runtime
owns a per-dispatch consumedCapture slot filled where its own captures
land. Runner-native refusals (direct iOS) carry no candidate shape and
pass through untouched.

The unit door previously over-claimed a sessionless assertion it never
made; it now pins retired-ref, sessionless, no-generation, and
unstored-capture refusals separately, and the reviewer's end-to-end
sequence gains its sparse twin. commands.md scopes the predicate claim
to the uniqueness rows (exists/absent never resolve one element) and
documents the count-only form.
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Head c6acd1e5e — blocker fix, the review's P1, and what is / is not verified

On the branch since 23bc27d09: the reviewer's blocker fix — a read refusal that prints candidate @refs now issues them (ADR 0014), so press <candidate ref> acts on the listed node.

The daemon test pins your sequence, not a variant (src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts): a session with an active complete frame over the interactive tree (as if snapshot -i just returned, where @e2 is a different button at (10,20)) → ambiguous is visible 'label="Deploy"' → copy the first printed candidate, pin it to the returned refsGeneration, press it → the tap lands at the listed node's center (310,310), proving it resolved against the tree that listed it. Paired negative: the unpinned body is refused with plain_ref_requires_complete_frame and the hint to retry with the exact emitted ref — never silently retargeted. Both drive real dispatchIsViaRuntime / handleInteractionCommands dispatch with only the runner transport mocked.

Verification honesty, plainly: the live RN Catalog run (is visible 'label="Catalog scroll: top"' → snapshot -i → ambiguous is visible 'role=text' → press a printed candidate) was not executed at these heads. The daemon test above is the verification for the issuance fix. The earlier live proof in the Validation section was at e04e85be2 and covers the ambiguity reporting (Part 1) on iPhone Duo / iOS 27.1, not the issuance change. A live re-run at this head is left to a human; two prior agent attempts at the live environment died without completing, and I am not burning a third.

This commit (c6acd1e5e) resolves the new review round (all three findings; replies in each thread):

  • P1 (sparse captures): confirmed reachable on the strict-read path and fixed at the rule. Candidates from a capture the session never stored (sparse-quality verdicts deliberately skip the store — the issueSettleRefs precedent) can no longer be issued against the previous tree's generation. Printing and issuing are one decision: no issuable frame → count-only refusal, no printed refs, no hint advertising an unusable affordance. Pinned by a sparse twin of the reviewer's sequence plus four daemon-unit branches (stored / retired ref / sessionless / no-generation).
  • P3 (test title): the retired-vs-sessionless over-claim is gone; the undefined-ref and no-generation branches are now separately asserted.
  • P3 (docs): the RN-pair bullet says "the uniqueness-based is predicates (not exists/absent, which never resolve one)"; the ambiguity paragraph documents the count-only form.

pnpm check:affected --run recorded at c6acd1e5e (one run): all runnable checks passed (1,441 unit files, layering, eager-closure ratchet, fallow, wire-compat, command-docs).

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Board reconciliation: the red Coverage run (37867039256) is against head 23bc27d09, before the placement fix shipped. ded07c0e5 moves the candidate cap beside the renderer it caps (cap defined once in snapshot-lines.ts, kernel keeps only the ElementMatchCandidateDetails type, the renderer's edge to kernel/errors was a single value import for one call site — the surfaces' "+N more" is derived from matches - candidates.length, never the constant). No baseline or budget file touched. Confirmed green at current head c6acd1e5e: Coverage pass in 8m19s, and the locally-recorded full check:affected run there passed all runnable checks including the eager-closure ratchet.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

The ambiguity fix in c6acd1e looks right, and the three earlier inline threads from cubic-dev-ai are now fixed at this head. All 21 checks pass at c6acd1e. The earlier red Coverage run was on 23bc27d, before the cap placement change. The Android live lane that reads the changed is reason is not among the checks.

One thing is still missing. The live run asked for in the previous review was not done at this head, and the only live run (e04e85b) predates ref issuance. is and get attrs now publish a partial ref frame, and press/fill now issue refs only when the consumed capture is the stored tree (session-snapshot.ts#L1044). Nothing shows yet that, on a real RN screen after snapshot -i, a printed candidate acts on the listed node. That depends on the real interactive and full capture numbering, which the iOS fixture only simulates. Please run the RN Catalog on iOS at c6acd1e (or the next head) and post the CLI output. Run snapshot -i first. Then an ambiguous is visible 'role=text' must return AMBIGUOUS_MATCH with candidates and details.refsGeneration. Then press <first candidate>~s<refsGeneration> must show the listed candidate's label in the resolved target. Finally, a plain press @<body> must be refused with plain_ref_requires_complete_frame.

Not blocking, and you can take or leave these: no test drives an ambiguous press through dispatchRuntimeInteraction and asserts refsGeneration (deleting the params.consumedCapture.state = snapshot assignment at interaction-runtime.ts#L69 turns every press/fill ambiguity into a count-only refusal and no test fails), so one handleInteractionCommands press test with an ambiguous label that asserts error.details.refsGeneration and refFrameScope equal to the candidate bodies would cover it; and the new docblock at session-snapshot.ts#L144 says every route answering AMBIGUOUS_MATCH with candidates goes through publishAmbiguousMatchCandidateRefs, but the find-act refusal (find-match-resolution.ts:68, returned unpublished at find.ts:186) prints candidates without issuing them, so either narrow the docblock or route that refusal through the helper in a follow-up.

On the earlier threads, the three from cubic-dev-ai are fixed at this head and can be resolved: the sparse issuance case now falls to a count-only refusal #3340 (comment), the sessionless and no-generation tests were added #3340 (comment), and the commands.md wording now excludes exists/absent #3340 (comment).

I did not run the daemon tests, so their pass status comes from CI and your recorded check run. I read the touch-route consumedCapture wiring but did not exercise it.

Before merge, the live iOS RN Catalog run above needs to be posted at the fixed head.

@thymikee
thymikee merged commit 6f451cc into main Oct 9, 2026
21 checks passed
@thymikee
thymikee deleted the fix/is-ambiguous-selector-2870 branch October 9, 2026 06:59
@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Live iOS RN Catalog gate — executed at head c6acd1e5e (runtime code identical; the follow-up commit adds only test + docblock)

Environment: iPhone Duo simulator (iOS 27.1), com.callstack.agentdevicelab against local Metro, Catalog tab active. The runner was rebuilt from this worktree's head; the session was then closed.

(a) snapshot -i first — activates the complete interactive frame:

Page: com.callstack.agentdevicelab
App: com.callstack.agentdevicelab
Snapshot: 28 visible nodes
@e1 [application] "Agent Device Tester"
@e2 [scroll-area] [scrollable]
  [content below scroll-area hidden]
@e3 [text] "Catalog"
@e4 [other] "catalog-title"
...

(b) ambiguous is → AMBIGUOUS_MATCH with candidates AND refsGeneration pinned into every emitted ref (~s367781):

$ agent-device is visible 'role=text'
Error (AMBIGUOUS_MATCH): Selector matched 22 elements: role=text
Hint: Narrow the selector with role/id/longer text, or act on a printed candidate with a command that takes refs, such as press.
Candidates:
  @e3~s367781 [text] "Catalog"
  @e4~s367781 [text] "Catalog"
  @e6~s367781 [text] "12 results"
  @e7~s367781 [text] "12 results"
  @e8~s367781 [text] "Search, filter, scroll, favorite, and drill into detail without extra dependencies."
  +17 more

(d) plain press @e2 — refused, never retargeted. After step (a) the active frame was COMPLETE (snapshot -i authorizes all), so the only thing between (a) and (d) that could replace it is the (b) refusal — its own partial frame over the capture it consumed, authorizing exactly the printed bodies. @e2 (the scroll-area) is not one of them, and the refusal confirms the frame now authorizes only emitted refs. Pressing it positionally against the new tree would have been exactly the wrong-node bind:

$ agent-device press '@e2'
Error (COMMAND_FAILED): Ref @e2 needs a complete snapshot — the current frame only authorizes its emitted refs
Hint: Capture a fresh interactive snapshot (snapshot -i) or use a stable selector, then retry.

(c) press <first candidate>~s<refsGeneration> — admitted and acted on the listed node:

$ agent-device press '@e3~s367781'
Tapped @e3 (148, 30)

The tap point matches the listed candidate's rect (fresh snapshot --json: StaticText "Catalog" at {x: 18, y: 12, width: 260, height: 36.33} → center (148.0, 30.17)). The admission itself is the decisive frame evidence: a pinned ref whose epoch differs from the active frame is rejected, so ~s367781 being admitted proves the active frame is the one the is refusal issued — and step (d) shows the earlier complete frame no longer authorizes anything. In this screen the candidate numbering happens to coincide between the two trees, so unlike the daemon test (where @e2 names deliberately different nodes), the live coordinates confirm the listed node's rect rather than discriminating trees by themselves; the (c)+(d) pair together pins the contract.

Bonus live proof of the acting-route rule at the same head: an ambiguous tab press before the frame existed answered with issued pins too:

$ agent-device press 'label="Catalog"'
Error (AMBIGUOUS_MATCH): Selector matched 2 distinct actionable elements: label="Catalog"
Candidates:
  @e3~s367778 [text] "Catalog"
  @e24~s367778 [button] "Catalog" [selected]

All four reviewer steps pass live at the fixed head. No failures, no workarounds; raw outputs above are verbatim CLI stdout/stderr.


The two confirmed non-blocking findings from the final round were not in the merged head (merged at 06:59:18Z, before that commit landed). They ship in follow-up PR #3347: the acting-route issuance coverage pin (mutation-checked: deleting the consumed-capture slot now fails exactly one test) and the docblock narrowing (find's refusal named explicitly as outside the rule's contract, with issuance there left as a deliberate maintainer decision).

thymikee added a commit that referenced this pull request Oct 9, 2026
… follow-up to #3340) (#3347)

* test(daemon): pin the acting route's own ambiguity issuance; narrow the rule's docblock

The review's two follow-ups, both confirmed at c6acd1e:

1) The acting seam had no test of its own. Deleting the touch runtime's
consumed-capture slot degraded every press/fill ambiguity to count-only
with no test red -- the existing press test pressed an is-minted ref,
traveling the selector-route seam. An ambiguous label= press through
handleInteractionCommands now asserts its OWN refusal carries
refsGeneration and a refFrameScope equal to the printed candidate
bodies. Mutation-checked: removing the slot fills makes exactly this
test fail.

2) The rule's docblock claimed every AMBIGUOUS_MATCH producer runs
through the helper; find's refusal (buildAmbiguousMatchError, #1597)
prints candidates without issuing them and never called it. Narrowed
to the routes that consume the rule, with the find shape named and its
contract stated: it prints no refsGeneration and its hint routes the
caller to narrow the locator, so it advertises no issued-ref
affordance. Routing find through the rule would make its candidates
issuable -- a separate contract change, left to the maintainer.

* docs(daemon): state find's missing generation pin, not an affordance claim

The review nit was right that the old phrasing leaned on an inference
("advertises no issued-ref affordance") that a reader could overread as
"find's candidates cannot be acted on" -- they are printed as ordinary
snapshot lines, and a plain ref against an unrelated complete frame is a
pre-existing admission the rule does not govern. The builder's own
message ("Use a more specific locator or selector.") and absent
refsGeneration remain the facts; state those directly and defer the
issuance question as the contract decision it is.
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.

is: an ambiguous selector reports selector_not_found; RN text yields duplicate-label nodes

1 participant