Skip to content

refactor: migrate get to the request-bound device runtime - #1877

Merged
thymikee merged 9 commits into
mainfrom
agent/wave4-get
Aug 20, 2026
Merged

refactor: migrate get to the request-bound device runtime#1877
thymikee merged 9 commits into
mainfrom
agent/wave4-get

Conversation

@thymikee

@thymikee thymikee commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Migrates the get descriptor onto a request-bound device runtime (ADR 0019 §6), Wave 4 of #1739.
Cutover rule id R36.

Along the way this PR also became the home of the selector capture seam (because get is its
first consuming command) and retired two legacy paths that the seam made retirable: the read
dispatch alias and get's direct-iOS selector shortcut.

Start record: #1739 (comment)

What lands here

1. get is runtime-backed. It declares selectorCaptureRuntimePlanUses — required
captureSnapshot / captureSnapshotWithoutActiveApp, preferred readTextAtPoint — resolves
its plan, inspects the exact owner's facts once, refuses before binding, and binds exactly once
through the admitRuntimePlan token.

2. The selector capture seam. Extracted from #1876, which could not ship it standalone: with
find's cutover deferred it had no consuming command (ADR 0019 §10) and was not dead-code clean
(check:production-exports 19 → 20). resolveBoundSelectorCapture returns a record of bound
operations, so readText is a second member rather than a signature change. wait and is are
stacked on this branch and consume it.

3. The read dispatch alias is retired, in full. Read-only find now constructs a bound
backend, so get text and find <q> get text execute the same bound readTextAtPoint. With no
consumer left, the whole chain is deleted: the read registry entry and its dispatch: {}
projection, DISPATCH_HANDLERS.read, handleReadCommand, the legacy adapter, the duplicated
platform reader branches, and the now-empty 'dispatch-alias' catalog group. find's descriptor
stays LEGACY_PLATFORM_EXECUTION — only its read leg moved, and it claims no cutover row.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName, so a surviving
DISPATCH_HANDLERS.read is a compile error rather than something R36 has to police.

4. The direct-iOS selector shortcut is retired. dispatchDirectIosSelectorGet reached
runAppleRunnerCommand through a path R36 declares no operation for; admitting before a bypass is
not executing through the seam. Every get target shape now resolves through the bound capture.
queryDirectIosSelector itself staysoffscreen-target probe still consumes it and it
remains single-copy.

Cost, stated plainly: get text id=… loses its tree-capture skip on iOS. A simple id selector
that previously answered from a direct runner query now captures the tree and resolves through it.
is measured the comparable shortcut at 0.14 s held / 0.25 s failed. No fallback was added and the
latency is not recovered anywhere. querySelector returns as a declared, fact-admitted, §9-measured
operation in a later unit that also moves the offscreen probe, retiring the root function in one
move — the joint-retirement shape that worked for read.

Retired

  • the get capability bucket, and requireCommandSupported admission for get;
  • 'get' from HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS;
  • the entire read chain (above);
  • dispatchDirectIosSelectorGet and its two now-unreachable helpers;
  • resolveBoundGetRuntime, get-runtime.ts and its test, elementReadRuntimeUse /
    elementReadRuntimePlan — all superseded by the seam rather than left alongside it;
  • src/platform-runtime-element-text-host.ts and its elementText host wiring.

Declared behavior changes

  1. watchOS get moves from capability-admitted-then-runner-failure to a typed unavailable
    admission refusal — the same sentinel snapshot and diff already landed.
  2. An unexpected live-read failure now fails get text on an editable iOS node instead of
    silently returning stale captured-tree text. That is the point of retiring the generic catch.
  3. get text id=… loses its fast path (above).

Test fixture change that is not incidental churn

makeSession in interaction-touch-fixtures.ts now returns makeIosAppSession. On an
iOS-family leaf the without-active-app capture row is unavailable, so a selector command run against
a session with no tracked app resolves the without-active-app plan and is refused at admission
before it captures
, returning the shared SESSION_NOT_FOUND "run open first". Reviewers should
connect this to the admission-refusal surface every selector command inherits, not read it as churn.

Validation

  • pnpm check:affected --run && git push: PASS — "all runnable checks passed".
  • unit-core: 938 files / 7,071 tests.
  • Layering: green. The cutover table parses to 21 distinct rows via the layering parser (not
    grep), with R36 and R39 both present and no silent merge.
  • fallow audit: clean on changed files.
  • Live evidence on iOS Simulator iPhone 17 Pro (F7D6F9A4-…, confirmed in the runner log): get text / get attrs over @ref and selector, both not-found paths, and the §9 measurement —
    required path alone 511 chars on a [truncated] node, bound preferred read 1,126 chars.

Red-before-green was captured for every regression: R36's 5 pre-cutover violations, the
admission-ordering regression (expected true to be false), the typed-outcome propagation
(promise resolved 'snap' instead of rejecting), and a planted TS2322 proving the exhaustiveness
guard bites — re-proved after the refactor that removed the runtime reason list.

Accounting — read this before the size discussion

Production only (excluding tests, fixtures, docs), from the merge-base: +753 / −238 = net +515.

Two corrections to figures quoted earlier in review:

  • An earlier +790 / −224 measurement was against a stale base.
  • The Interactor change was expected to remove ~89 lines by deleting
    platform-runtime-element-text-host.ts. It bought ~4, not ~89 (net +520 → +516 before a lint
    fix). The four readers still have to exist — they relocated into the Apple/Android/Linux
    interactors, and the contract's binder absorbed the resolve/guard logic. Only the port vanished.
    That change was made for consistency of mechanism (findText and readTextAtPoint now reach
    their mechanics the same way, so Wave 5/6 retires one seam instead of two) and it delivers that
    fully — but it should not be read as having delivered a size win it did not.

Per #1842, the CI Size report on this head is the authority; pnpm size --base <ref> does not
exist and local comparisons are not the evidence.

Structural outcomes from the size investigation: ElementTextRuntimeExecution was a byte-identical
duplicate of SnapshotRuntimeExecution (removed, reused); FrozenUnavailablePlatformRuntimeFacts is
now derived (Required<Omit<…>>) rather than restated, so a future cell cannot be added to one
and forgotten in the other; and a stale comment claiming a duplication this branch had already
retired was removed.

Known gaps

  • Not the last selector unit: captureSnapshot keeps its optional captureData? seam and the
    legacy captureSnapshotWithInteractor fallback.
  • CaptureSnapshotInput.signal is not on this branch — it belongs to wait (refactor: migrate wait to request-bound runtime #1875), along with
    the regression proving per-poll abort and quiescence.
  • snapshotPlanUnavailableResponse is deliberately not exported; it lands with wait, its
    first external consumer.
  • Android, HarmonyOS, Linux, Web and every provider row are covered by fact/parity evidence only.

Docs and skills are unchanged: CLI grammar, help, and user-visible get behavior are identical
apart from the declared changes above.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.33 MB 2.33 MB +1.9 kB
JS gzip 765.6 kB 766.3 kB +697 B
npm tarball 890.3 kB 891.0 kB +632 B
npm unpacked 3.10 MB 3.10 MB +2.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 26.8 ms -0.7 ms
CLI --help 71.7 ms 69.0 ms -2.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-batch-runner.js +1.1 kB +241 B
dist/src/dispatch.js -1.1 kB -215 B
dist/src/internal/daemon.js +627 B +122 B
dist/src/runtime4.js +500 B +90 B
dist/src/runtime2.js +272 B +44 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 28b327ff0f36128f1024d5af752b3a2267c80979. Two code blockers remain:

  1. dispatchGetViaRuntime can still complete the direct-iOS selector path before resolveBoundGetRuntime. Once get declares platformExecution: device-runtime, ADR 0019 requires the full descriptor path to resolve/admit/bind before operating; calling this branch shared debt does not remove it from get’s denominator. It bypasses exact-owner facts/admission and the one-binding invariant. Move this path behind the admitted binding (or keep the descriptor legacy until its owning seam can move), with regression evidence that an eligible direct selector cannot operate before admission.

  2. readTextAtPoint returns Promise<string> and readTextForNode still catches any thrown error and falls back. The interaction_read_fallback diagnostic is assigned after the untyped failure; it is not the typed-reason outcome ADR 0019 §2 requires. Return a closed typed preferred-operation outcome, fall back only for its classified reasons, add the corresponding ADR 0011 path classification/contract coverage, and let unexpected errors propagate. This also restores the start record’s promised retirement of the generic catch.

The branch’s own reconciliation and size blockers also remain: it must reconcile with find to retire the duplicated read path, and the stated size budget is exceeded. Coverage’s sole ENOTEMPTY cleanup failure in request-save-script-transports.test.ts appears unrelated/infra-shaped and needs a green rerun after the code changes. No ready-for-human yet.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
@thymikee

Copy link
Copy Markdown
Member Author

Both code blockers resolved

New head: c666150dfb5ce03449eb9dd15d5dada75fa5d1db (previous reviewed head 28b327ff0). Still draft.

Blocker 1 — direct-iOS selector path completed before admission

Fixed by ordering, not by reverting the descriptor. dispatchGetViaRuntime now runs
resolveBoundGetRuntime — resolve → admit → bind — before any target shape can reach the
device, including the ones the direct-iOS fast path can answer. The fast path is now a fast path
within an admitted request: exact-owner facts are inspected, admission can refuse, and the single
binding exists before the runner is ever queried.

To be explicit about what this does and does not claim: queryDirectIosSelector itself is still
the shared root mechanic co-owned by is, wait, and the Wave 5 offscreen-target probe. This unit
orders it inside get's request path; it does not claim its migration. That was the
distinction I had conflated — ownership versus ordering — and the review was right that ordering
is mine regardless of who owns the mechanic.

Red before green. New regression an eligible direct iOS selector cannot operate before admission: an id= selector the fast path would answer, on a device whose facts refuse
captureSnapshot. Against the pre-fix ordering:

FAIL  src/daemon/handlers/__tests__/interaction.test.ts >
      an eligible direct iOS selector cannot operate before admission
AssertionError: expected true to be false
  - false
  + true
  ❯ 259|   expect(response?.ok).toBe(false);

The fast path answered ok: true having skipped exact-owner facts entirely — exactly the bypass.
After the reorder it passes, and the test also asserts runAppleRunnerCommand was never called.

Blocker 2 — untyped fallback

readTextAtPoint now returns a closed ElementTextReadOutcome:

| Readonly<{ status: 'read'; text: string }>
| Readonly<{ status: 'unreadable'; reason: ElementTextUnreadableReason }>

with ElementTextUnreadableReason derived from its runtime list
(ELEMENT_TEXT_UNREADABLE_REASONS) rather than declared beside it, so the enumeration and the type
cannot drift. Reasons are no-text-at-point and surface-not-readable.

readTextForNode has no try/catch. It falls back to the captured tree only for a classified
reason, and classifiedFallbackReason switches exhaustively with a never arm — so an unexpected
error propagates and a new reason is a compile error at the consumer rather than a silent untyped
fallback. Blank owner answers are classified as no-text-at-point by elementTextRead instead of
being guessed at downstream, which removes the old "empty or failed?" ambiguity. Each host reader
classifies its own owner's "nothing here" answer; none of them catches.

This is the generic-catch retirement my start record promised, so it is a deletion I already owed.

Red before green. New regression propagates an unexpected live-read failure instead of falling back. With the pre-fix generic catch restored:

FAIL  src/daemon/handlers/__tests__/interaction-read.test.ts >
      readTextForNode > propagates an unexpected live-read failure instead of falling back
AssertionError: promise resolved "'snap'" instead of rejecting

Planted violation for the exhaustiveness gate. Adding a third reason to the single source of
truth without handling it:

src/daemon/handlers/interaction-read.ts(98,13):
  error TS2322: Type '"planted-unhandled-reason"' is not assignable to type 'never'.
FAIL packages/contracts/src/element-text-runtime.test.ts >
     the unreadable reason list is exhaustive over the reason union

Both the compiler and the contract test go red; both are green with the planting removed.

Contract coverage: packages/contracts/src/element-text-runtime.test.ts (8 tests) pins the
closed union, the exhaustive reason list, blank/undefined/null classification, exact text
preservation, and outcome freezing.

On the ADR 0011 path classification

I did not add a row to INTERACTION_DISPATCH_PATHS, and want that decision visible rather than
silently skipped. That matrix is scoped to mutating interaction dispatch paths — its command sets
are press/click/fill/longpress/hover/gesture, and its guarantees are disambiguation,
occlusion, parentOwnedTouchPoint, offscreen, nonHittable, verifyEvidence,
settleObservation. get is an observation command and the element-text read performs no
interaction, so every one of those cells would have to be inapplicable; the completeness gate
would be satisfied by a row that asserts nothing. ADR 0019 §2 asks for a "descriptor/ADR 0011 path
classification" — I took the descriptor half: the operation is declared preferred on the
descriptor's use, and the fallback is classified by the closed typed outcome above with contract
coverage. If you would rather see the matrix row anyway, say so and I will add it, but I did not
want to manufacture a vacuous one.

Behavior change, declared

An unexpected live-read failure (runner transport, helper crash) during get text on an
editable/expandable iOS node now fails the command instead of silently returning the captured
tree's text. That is the intended consequence of retiring the catch: answering from a stale tree
after an unclassified failure was the bug, not the feature.

Validation

  • Full vitest run --project unit-core behind the shared lock: 923 files / 6,984 tests pass
    (was 921 / 6,964; the delta is this change's new coverage).
  • pnpm check:layering: green, get still keeps exactly one platform-execution path.
  • fallow audit: clean on changed files. It caught an unused ELEMENT_TEXT_UNREADABLE_REASONS
    re-export on the platform facade during this pass; dropped it (root code needs only the type).
  • Typecheck across all packages + root: green.
  • pnpm check:affected --run && git push: PASS — "all runnable checks passed", pushed 28b327ff0..c666150df.

One correction worth recording, because it cost a cycle and generalizes. An earlier attempt
reported success that was not real: my chain was gate && push; echo "CHAIN_EXIT=$?", so the
trailing echo always exits 0 and the task notification said "exit code 0" while the log said
CHAIN_EXIT=1 / check:affected: layering failed. The && did its job — nothing was pushed —
but I reported a landing that had not happened. Read the log, not the wrapper's exit code.

That failure was itself a real finding: only check:affected runs the layering node tests.
A standalone scripts/layering/check.ts run — which is what I had been validating with — does not,
so it measures a narrower gate than CI. What it caught was facade-exports: an explicit façade
list must stay exhaustive over its source module, and I had removed
ELEMENT_TEXT_UNREADABLE_REASONS from the façade in the previous pass to satisfy fallow's
unused-re-export finding. The two gates pull in opposite directions over the same symbol.

I resolved it by satisfying neither: the runtime reason list is deleted outright. Exhaustiveness
was never enforced by it — it is enforced at the consumer by classifiedFallbackReason's never
arm, which is what the planted violation proves. The list was a second source of truth with no
consumer, waiting to drift; fallow was right that nobody used it. The union is now declared
directly and the contracts test keeps a local, type-annotated list for iteration.

The planted TS2322 was re-proved after that refactor, because an evidence claim has to
survive its own cleanup:

src/daemon/handlers/interaction-read.ts(98,13):
  error TS2322: Type '"planted-unhandled-reason"' is not assignable to type 'never'.

Live re-verification

Re-run on the assigned iOS Simulator iPhone 17 Pro (F7D6F9A4-…) after a rebuild and
pnpm clean:daemon, because both fixes change runtime behavior:

  • get text @ref, get attrs @ref, get text <selector>, and both not-found failure paths: all
    correct.
  • The direct-iOS fast path still fires after admission — querySelector runner count 9 → 12
    across the get text id=com.apple.settings.general request, which answered General.
  • Zero legacy read dispatches across every request in the session.
  • The §9 preferred-operation measurement holds under the typed outcome: required path alone
    511 chars on a [truncated] node, typed preferred read 1,126 chars, 3 readText runner
    calls. Session closed.

Still open, per the coordinator's sequencing

The read joint retirement (approved) and the rebase onto the re-scoped seam PR come next, in that
order, once #1876 merges. I have not re-baselined the size budget; the expectation is that removing
the duplicated read branches moves those numbers, and they will be re-measured then.

The scratch-dir wart is fixed — it now lives outside the worktree, so check:affected no longer
pulls .scratch/* into its coverage file set.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head c666150df. The typed preferred-read outcome is a real fix: classified fallbacks are closed, unexpected errors propagate, and the regression is non-vacuous. Two architecture blockers remain. First, successful direct-iOS get now occurs after admission, but still executes queryDirectIosSelector / runAppleRunnerCommand outside the selected bound runtime; the new test proves refusal ordering only. A device-runtime descriptor must operate through a declared narrowed operation, or remain legacy until that shared seam moves. Second, ADR 0019 §2 also requires explicit ADR 0011 path classification and contract coverage for the preferred fallback, and this head adds neither. Complete the planned #1876 reconciliation and joint read retirement before re-review: current growth is +739 net production lines and CI reports +5.1 kB npm unpacked, both heightened-review triggers, while duplicated legacy/read host paths remain. Android Smoke is an infrastructure rerun (unknown archive while installing the SDK image), not owner-code evidence. No ready-for-human.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
@thymikee

Copy link
Copy Markdown
Member Author

The selector capture seam lands here, with get as its first consumer

New head: 4778a27512ee2e1c8e8c0efb7833ee4da908f792. Rebased onto #1876 @ 30435df1b (stacked, not waiting for merge).

Why the seam is in this PR and not #1876

It cannot ship standalone. ADR 0019 §10 prohibits a facet PR with no consuming command, and §6
admits an early substrate only when it is dead-code clean. With find's cutover deferred behind
the Wave 5 focus/type surfaces, nothing in production consumed the selector half and
check:production-exports went 19 → 20. get is its first consuming command, so it lands with
get. Same precedent as wave 0, where gateway facts inspection landed inside boot rather than
ahead of it.

The design is find's, adopted as handed over — resolveBoundSelectorCapture,
createBoundSelectorRuntime, the bound threading, selectorCaptureFixture, makeIosAppSession.

The one shape change, and why it was necessary

BoundSelectorOperations was built as a record precisely so the next unit could add its own
bound operation. But the read could not be populated: resolveSelectorCaptureRuntimePlan reused
captureSnapshotUse / captureSnapshotWithoutActiveAppUse — the same uses snapshot/diff bind
— which declare no preferred read, so the narrowed runtime never exposed one.

The alternative was dropping readTextAtPoint from get's use, which would have been a live-read
regression
for get text on editable iOS nodes (the measured 511 → 1,126 character case) and
would have discarded the §9 measurement that justifies the preferred declaration at all. This
wave does not narrow behavior to make a migration easier.

So, approved by the coordinator and constrained to be additive:

  • selectorCaptureUse / selectorCaptureWithoutActiveAppUse are declared alongside the
    snapshot uses. captureSnapshotUse and captureSnapshotWithoutActiveAppUse are unchanged and
    snapshot/diff bind exactly what they bound before; the only difference is
    preferred: ['readTextAtPoint'].
  • The read is surfaced through the existing arms of bindSnapshotCaptureRuntime, which reuse
    the same selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors and compose
    selectElementRead onto them. One switch, two composed arms — no duplicated operation-selection
    logic and no second plan-to-operation dispatch.
  • admitAndBindSnapshotCapture gains an optional readTextAtPoint on its success shape.
    snapshot/diff never populate it.

Why the discriminants differ. Sharing 'active-app' / 'without-active-app' across both plan
families broke twice: a union-typed plan.use defeats bind's inference, and
{ readTextAtPoint?: … } is a weak type, so a snapshot projection with no overlapping key is
not assignable. Distinct discriminants ('selector-active-app', 'selector-without-active-app')
let the compiler narrow per family. The alternative was a cast to paper over an assignability hole,
which AGENTS.md rules out — typed signals beat working around the compiler.

What the seam replaced rather than accreted next to

  • resolveBoundGetRuntime and src/daemon/get-runtime.tsdeleted
  • src/daemon/__tests__/get-runtime.test.tsdeleted
  • elementReadRuntimeUse, elementReadRuntimePlan, and their façade exports — deleted
  • 'get' removed from the createSelectorRuntime capability union ('find' | 'is' remains;
    'find' survives until find's own flip, and is deletes the function outright)

get now runs through createBoundSelectorRuntime. R36's operation owners moved to the shared
seam: captureSnapshotselectActiveAppSnapshot, captureSnapshotWithoutActiveApp
selectSnapshotWithoutActiveApp, readTextAtPointbindElementRead.

Test fixture change that is NOT incidental churn

makeSession in src/daemon/handlers/__tests__/interaction-touch-fixtures.ts now returns
makeIosAppSession instead of makeIosSession. This is a real behavior surface, not tidying:

On an iOS-family leaf the without-active-app capture row is unavailable. A selector command run
against a session with no tracked app therefore resolves the without-active-app plan, is refused
at admission before it ever captures
, and returns the shared SESSION_NOT_FOUND "requires an
active app session … run open first" — the same normalization the merged snapshot and diff
units already took on this exact operation. It cost three test failures before I applied it.

Reviewers should connect this to the open question being grilled for wait, rather than reading it
as test churn: it is the same admission-refusal surface, and every selector command inherits it.

The read retirement stays deferred, deliberately

readTextForNode still serves find <q> get text, which reaches it through the legacy
createSelectorRuntime. So the four platform read branches remain duplicated
(handleReadCommand vs src/platform-runtime-element-text-host.ts) and my size breach is only
partly fixable here. That is recorded on the tracker and belongs to find's deferred cutover
scope alongside focus/type.

Why the remaining adapter is not a forbidden fallback (ADR 0019 §6). Which read the shared
backend uses is fixed by which command constructed the runtime — never by failure, family,
environment, or flag. A migrated command arrives with bound and its binding is authoritative,
including when the owner advertised no read, which is the complete required path. An unmigrated
selector command arrives without bound and keeps the legacy dispatch until its own descriptor
cuts over. get cannot reach the legacy adapter under any condition. That is the unmigrated
sibling's own path, which §6 permits — not a fallback for a migrated command.

Validation

  • Typecheck across all 17 packages + root: clean.
  • vitest run --project unit-core behind the shared lock: 924 files / 6,988 tests pass.
  • pnpm check:layering: green; get remains in the migrated-command list keeping exactly one
    platform-execution path.
  • fallow audit: clean on 73 changed files.
  • pnpm check:affected --run && git push: PASS — "all runnable checks passed", pushed c666150df...4778a2751 (forced update: the rebase onto refactor(daemon): one capture-input builder and one admit-then-bind step #1876 rewrote history; nothing was stacked on this branch at that point, and it is now a shared base that will not be rewritten without flagging).

For the units stacking on this branch

wait and is consume this seam. Two things not to drop:

  • CaptureSnapshotInput.signal + captureSnapshotSignal (from refactor(daemon): one capture-input builder and one admit-then-bind step #1876) are load-bearing for
    wait's poll deadline — each poll aborts that capture and waits for it to quiesce rather than
    racing and abandoning it, so a late capture cannot mutate session state.
  • BoundSelectorOperations is a record with capture and optional readText. Add operations as
    members; do not reshape it.

Per the coordinator's ruling I have not exported snapshotPlanUnavailableResponse — that lands
in wait's PR, which introduces its first external consumer.

@thymikee

Copy link
Copy Markdown
Member Author

Live re-verification of the shipped seam path

The seam swap changed both how get captures and how it reads, so the earlier evidence no longer
covered what ships. Re-run on the assigned iOS Simulator iPhone 17 Pro
(F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9, confirmed in the runner log — no other device touched)
after pnpm build && pnpm clean:daemon, at head 4778a2751:

  • get text @ref, get attrs @ref, get text <selector>, and both not-found failure paths: all
    correct through createBoundSelectorRuntime.
  • The direct-iOS fast path still fires after admission — querySelector runner count 0 → 3
    across the get text id=com.apple.settings.general request, which answered General.
  • The bound preferred read reaches the platform through the seam: 3 readText runner calls.
  • Zero legacy read dispatches across every request in the session, so get never touched the
    adapter that still serves find <q> get text.
  • The §9 measurement holds unchanged under the seam: required path alone 511 chars on a
    [truncated] node, bound preferred read 1,126 chars.

Session closed; no sessions left open.

@thymikee

Copy link
Copy Markdown
Member Author

Blocking: #1739’s batch decision requires the shared read path to retire jointly, even while find remains legacy for focus/type. This head still lets read-only find construct an unbound backend, whose interaction-read-legacy-dispatch calls dispatchCommand(device, "read"); therefore the read registry/dispatch projection, DISPATCH_HANDLERS.read, handleReadCommand, and duplicate platform reader branches all ship. Route find … get text through the same bound selector operations, delete that legacy chain, and re-measure against the posted budget. The exact-head Size report is still +4.7 kB unpacked (+1.6 kB gzip), and no budget revision has been approved. Prior direct-iOS ordering and untyped-fallback blockers are resolved. CI is otherwise green; iOS Smoke remains in progress; still draft. No ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

Out of draft at 4778a2751. All 28 required checks green, and the base is retargeted to agent/wave4-find so this PR's diff now shows its own 53 files rather than 60 including #1876's commit.

Stack: #1875#1877#1876main. GitHub will auto-retarget this to main when #1876 merges; a real rebase follows then, and since #1875 is stacked on this branch that rewrite will be coordinated rather than force-pushed under it.

Both blockers from your review at 28b327ff0 are fixed, each with red-before-green:

  • Admission now precedes the direct-iOS fast path. Red: expected true to be false — the fast path answered ok: true on a device whose facts refuse captureSnapshot, having skipped exact-owner facts entirely. The regression also asserts runAppleRunnerCommand was never called.
  • readTextAtPoint returns a closed ElementTextReadOutcome; readTextForNode has no try/catch, falls back only for classified reasons, and switches exhaustively with a never arm. Red: promise resolved "'snap'" instead of rejecting. Planting a third reason goes red in both gates (TS2322 … not assignable to type 'never' plus the contracts exhaustiveness test).

This PR also carries the selector capture seam, because get is its first consuming command — ADR 0019 line 686 prohibits a facet PR with no consuming command, and line 448 requires an early substrate to be dead-code clean, which a standalone seam was not (check:production-exports 19 → 20). The seam replaced rather than accreted: resolveBoundGetRuntime, get-runtime.ts and its test, and elementReadRuntimeUse/elementReadRuntimePlan with their façade exports are all deleted.

Three things worth the hardest look:

  1. The read duplication is real and deferred. The start record's classification was wrong — find <q> get text is a second live consumer — so the four platform read branches exist twice until find's cutover. The surviving adapter is selected by which command constructed the runtime, never by failure, family, environment, or flag, and get cannot reach it; that is the unmigrated sibling's own path, which §6 permits. The size budget is breached because of this and was not re-baselined.
  2. A behavior change is declared, not buried: an unexpected live-read failure on an editable iOS node now fails get text instead of silently returning stale tree text.
  3. makeSessionmakeIosAppSession is a behavior surface, not test churn — it is the same iOS no-app-session admission question investigated for wait, now filed as iOS: selector commands displace the app under test and return false negatives when no app session is tracked #1881.

Live-verified at this exact head on iPhone 17 Pro: all get paths through createBoundSelectorRuntime, direct-iOS fast path firing after admission (querySelector 0 → 3), 3 bound readText runner calls, zero legacy read dispatches, and the §9 measurement unchanged at 511 → 1,126 chars.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
Base automatically changed from agent/wave4-find to main August 19, 2026 15:34
thymikee pushed a commit that referenced this pull request Aug 19, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
@thymikee

Copy link
Copy Markdown
Member Author

Blocking on exact head 52a1c9d: get still calls dispatchDirectIosSelectorGet after createBoundSelectorRuntime; that successful path reaches queryDirectIosSelector / the raw Apple runner outside the selected runtime projection. Admission ordering alone is insufficient because the bound operations are unused. The paragraph-long comment explaining why this exception is acceptable is evidence that the invariant is not obvious in code. Per the tracker ruling, retire the get shortcut (keeping the shared helper only for the offscreen probe), and make the eligible id= regression prove it uses the admitted capture path. Also refresh the body: it still describes the old base and deferred read alias although this head completed that retirement. No ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

That review is against 52a1c9d, which was stage 1 of a two-stage push. The shortcut retirement you asked for is already on the branch at aa12c9ff418424367b895b41ef4eba43d7b455d1 — pushed as a fast-forward on top of 52a1c9d, so nothing was rewritten.

Verified on the pushed head:

symbol refs in selector-runtime.ts
dispatchDirectIosSelectorGet 0
dispatchDirectIosSelectorIs 2 — deliberately left for #1883
queryDirectIosSelector 5 — survives for the offscreen probe

Per the tracker ruling, the shortcut is retired in get and is and the shared helper stays single-copy for offscreen-target-probe.ts, which takes a plain session and cannot consume a bound operation. The deciding argument was that declaring querySelector now would ship it twice until Wave 5 moves the probe — the same deferred-duplication shape that this PR's read deferral was overruled for.

Two helpers went unreachable with the caller and are deleted: readDirectIosGetSelector and buildDirectIosGetResult. Both were get-only.

On the id= regression: two existing tests pinned the bypass. They were not deleted quietly — get text simple iOS id selector uses runner query without snapshot is replaced with one asserting the new truth (resolves through the bound capture, querySelector never called), and the ambiguity companion is removed with reasoning, since it asserted a refusal shape only the shortcut produced; ambiguity on that path now comes from the snapshot pipeline, which is already covered.

R36's legacyRetirement now names dispatchDirectIosSelectorGet, so the row asserts the retirement rather than describing the seam.

A cost stated rather than buried: this is a real latency regression. get text id=… no longer skips the tree capture, and nothing recovers it — the comparable path measures ~0.14 s → ~0.25 s. The deferred capability is recorded: querySelector returns as a declared, fact-admitted, §9-measured operation in a later unit that also moves the offscreen probe, retiring the root function in one move. When it is declared, get owes its own measurement; borrowing is's numbers will not do.

The body refresh you asked for is in progress along with the CI Size figure and iPhone 17 Pro re-verification on this final head — you are right that it still describes the old base and the deferred read alias, both of which this head completed.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head aa12c9ff4: the prior code blocker is resolved. get no longer reaches the raw direct-iOS shortcut, its get-only helpers are gone, the eligible id= regression now proves the bound capture path and rejects querySelector, and R36 owns the retirement. The joint legacy read chain is also fully retired; I found no new code blocker in this delta.

Not ready yet: the branch conflicts with current main, the PR body still describes the old base/deferred read alias, iOS Smoke failed with infra-shaped wait_capture_stalled (readableCaptures: 0), and Linux Smoke was cancelled. Rebase, resolve the interaction-fixture/test and ratchet conflicts, refresh the body/evidence at the resulting head, and rerun both smoke lanes. No ready-for-human until then.

thymikee pushed a commit that referenced this pull request Aug 20, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
@thymikee

Copy link
Copy Markdown
Member Author

Live verification at 9e59a55bd — the retired bypass, confirmed on a real device

Re-run on the assigned iOS Simulator iPhone 17 Pro
(F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9, confirmed in the runner log — no other device touched)
after pnpm build && pnpm clean:daemon. Session opened and closed; none left open.

The direct evidence that the bypass is gone. get text id=com.apple.settings.general is the
exact command that previously took the direct-iOS runner query. Measured across that request:

before (pre-retirement head) now (9e59a55bd)
querySelector runner commands 0 → 3 0 → 0
answer General General

The command still answers correctly and issues zero querySelector runner commands — it now
resolves through the bound capture like every other get shape. That is the on-device counterpart
of the unit test that replaced the one asserting the old behavior.

Every path, including the retired path's failure case:

  • get text @refGeneral; get attrs @ref → correct node
  • get text label=…Accessibility
  • get text label=NoSuchThingCOMMAND_FAILED: Selector did not match
  • get text id=no.such.identifierCOMMAND_FAILED: Selector did not match — the not-found
    case of the retired path, now answered by the snapshot pipeline rather than the runner query
  • get text @e999COMMAND_FAILED: Ref @e999 not found

The two retired chains, still absent:

  • querySelector runner commands: 0
  • legacy read dispatches (platform_command_prepare with command: "read"): 0

The §9 measurement holds unchanged through the Interactor seam — the preferred read now
resolves via localInteractors.resolve rather than the deleted host port, and recovers the same
text:

  • required path alone (captured tree, get attrs value): 511 chars on a [truncated] node
  • bound preferred readTextAtPoint: 1,126 chars, via 3 readText runner calls

So the mechanism change is behaviour-neutral on device, and the latency cost of the retirement is
confined to what was declared: get text id=… now captures the tree instead of skipping it.

thymikee pushed a commit that referenced this pull request Aug 20, 2026
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 865145e1c. The joint read retirement and direct-iOS bypass blockers are resolved, the production route now admits/binds once through the selector-capture seam, and all 28 exact-head checks are green.

One contract blocker remains: bindElementTextRuntime treats an owner whose facts advertised readTextAtPoint but whose resolved interactor lacks it as classified surface-not-readable. ADR 0019 requires advertised-without-implementation to be runtime-contract-invalid; the current branch instead permits fallback to captured text, hiding owner/fact drift. surface-not-readable has no real backend producer and its test injects the outcome directly. Fail this mismatch as a runtime-contract error and add a regression that proves it cannot fall back.

Size is accepted by the recorded #1739 ruling now that the duplicated read path is retired; refresh the body with that ruling and the exact-head CI deltas (+3.5 kB raw, +1.2 kB gzip, +1.0 kB tarball, +4.0 kB unpacked). No ready-for-human yet.

agent and others added 8 commits August 20, 2026 13:41
`get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred
`readTextAtPoint`), admits once from exact owner facts, refuses before binding,
and binds exactly once. Its capability bucket, the static HarmonyOS/Web command
sets that augmented it, and `requireCommandSupported` admission for `get` are
gone; `'get'` leaves the `createSelectorRuntime` capability union.

The neutral `readTextAtPoint` operation replaces the branch-per-family legacy
`read` dispatch on the `get` path. Every local family and both providers now
classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it
unavailable, which is behaviour-preserving because the legacy dispatch had no arm
for them and threw on every call before falling back.

R36 is the new parametrized cutover row.
…ad outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.
…nsumer

Takes ownership of the request-bound selector capture seam from #1876, which
cannot ship standalone: with find's cutover deferred it had no consuming
command (ADR 0019 §10) and was not dead-code clean (check:production-exports
19 -> 20). `get` is its first consumer, so it lands here.

Adopts find's handoff as given. The one shape change, approved by the
coordinator: the selector family gets its own capture uses carrying a PREFERRED
`readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff`
keep binding exactly what they bind today. The read is surfaced through the
existing arms of `bindSnapshotCaptureRuntime`, reusing the same
selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second
plan-to-operation dispatch.

`get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime`
and its test are deleted as superseded, and `'get'` leaves the
`createSelectorRuntime` capability union.

The legacy read adapter survives for `find <q> get text` and is selected by
which command constructed the runtime — never by failure, family, environment,
or flag — so `get` cannot reach it. It retires in find's cutover, where the
last consumer moves.
Read-only `find` now constructs a BOUND selector backend, so `get text` and
`find <q> get text` execute the same bound `readTextAtPoint` instead of one
binding it and the other dispatching the legacy `read`. This moves find's READ
LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no
cutover row.

With no consumer left, the whole chain goes: the `read` registry entry and its
`dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`,
`interaction-read-legacy-dispatch.ts`, and the duplicate platform reader
branches it carried. `read` was the only `dispatch-alias` descriptor, so that
catalog group goes too.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName,
which makes a surviving DISPATCH_HANDLERS.read a compile error rather than
something R36 has to police. R36 now claims the retirement it can prove.

`find.test.ts` is over the size tripwire, so its handler invocation is
extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221.
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
`get` declares device-runtime, so its request path must reach the platform only
through operations R36 declares. `dispatchDirectIosSelectorGet` reached
`runAppleRunnerCommand` through a path the row declares no operation for;
admitting before a bypass is not executing through the seam, so the bypass is
removed rather than ordered after admission. Every target shape — including the
simple iOS `id=` selector — now resolves through the bound capture.

`queryDirectIosSelector` itself stays: `offscreen-target-probe.ts` still
consumes it and it remains single-copy. `dispatchDirectIosSelectorIs` belongs to
`is` (#1883). Two get-only helpers (`readDirectIosGetSelector`,
`buildDirectIosGetResult`) became unreachable and are deleted with the caller.

Declaring `querySelector` as a fact-admitted preferred operation was rejected on
duplication, not correctness: the offscreen probe takes a plain session and
cannot consume a bound operation, so it would ship the query twice until Wave 5
moves the probe — the deferred-duplication shape this PR was already overruled
for on the `read` alias. It returns as a declared, §9-measured operation in a
later unit that also moves the probe.

Cost, stated plainly: `get text id=…` loses its tree-capture skip on iOS. No
fallback was added and the latency is not recovered elsewhere. R36's
singularExecution claim is now what the code does rather than aspirational.
…poke host

Two operations of the same class were reaching their mechanics two different
ways: `findText` rides `Interactor` via `localInteractors.resolve`, while
`readTextAtPoint` had its own host port. That is duplication of MECHANISM, so
the read now rides the same seam.

`Interactor` gains `readTextAtPoint?`, implemented on the Apple, Android and
Linux interactors where those mechanics already live.
`src/platform-runtime-element-text-host.ts` and its `elementText` host wiring
are deleted; the contract binds through the resolver exactly as the snapshot
runtime does.

Size honesty: this removes an 89-line module but the four readers still have to
exist, so they moved into the interactors rather than vanishing. Net production
change is ~4 lines, not ~89. The duplication of mechanism is what is actually
fixed; Wave 5/6 retires the seam for both operations together.

Also from the size investigation:
- `ElementTextRuntimeExecution` was byte-identical to `SnapshotRuntimeExecution`;
  removed and reused, as `find-text-runtime.ts` does.
- Removed a stranded, stale comment in `selector-capture-binding.ts` that still
  claimed a duplication this branch had already retired.
- `FrozenUnavailablePlatformRuntimeFacts` is derived from its input type rather
  than restated, removing a 14-line clone group my new cell had pushed over the
  detector threshold.
* refactor: migrate is to the request-bound device runtime

`is` declares the shared selector capture use, admits once from exact owner
facts, refuses before binding, and binds exactly once. Its capability bucket,
the static HarmonyOS/Web command sets that augmented it, and
`requireCommandSupported` admission for `is` are gone; `'is'` leaves the
`createSelectorRuntime` capability union.

Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires
resolve -> admit -> bind before anything in a `device-runtime` command's request
path reaches the device, so that query becomes a fast path *within* an admitted
request rather than a way around exact-owner facts. The rule is documented once,
on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments
`get` and `is` were each carrying.

Declared behaviour change: `is` takes the active-app plan split, so the facts
decide per family. On iOS `appBundleId` is the XCUITest attach target — with no
tracked app the runner's own process comes to the foreground, displaces the app
under test, and the capture then answers confidently about the runner's own
blank screen. An iOS `is` on a session with no tracked app is now a typed
SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats
displacing-and-lying. Android captures the real launcher in that state and is
unchanged, which is what the platform facts already encoded.

The two Apple watchOS cells move from capability-admitted-then-runner-failure to
a typed unavailable refusal, the same classification snapshot, diff, and get
already landed.

R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime`
and its `requireCommandSupported` call, so `captureData` stays optional and
`captureSnapshotWithInteractor` stays: this unit is not the last selector unit.

* fix(is): a failing iOS assertion fails instead of exiting zero

Reverses part of #557, on thymikee's explicit instruction.

`is` is an assertion: the docs state it "exits non-zero on failure". The
direct-iOS fast path broke that contract — it reported a failed predicate as a
completed command, so on device

    $ agent-device is text id=… "Wrong Expected Text"
    Passed: is text          (exit 0)

because `{ok: true, pass: false}` reaches `isCliOutput`, which renders
"Passed: is <predicate>" without reading `pass`. A failing assertion reported as
success lets a replay run on past a broken state. Now:

    Error (COMMAND_FAILED): is text failed for selector id=…:
      expected="Wrong Expected Text" actual="Apple Account, …"   (exit 1)

The renderer needed no patch: a negative can no longer produce a success
envelope, so it is correct by construction.

Direction chosen deliberately. Making the two paths agree could have gone either
way, and "an agent asked a question and got an answer" is a real argument for the
other one. This follows the DOCUMENTED contract rather than merely the incumbent
behaviour, and the alternative is a far larger change: a zero-exit `is` would
alter every platform and path, break scripts that rely on it failing the shell,
and needs its own PR, docs, and probably a major version. It is also already how
`is hidden` and `is exists` behave end to end.

PASSING assertion, and that arm still answers with zero captures (pinned). Only
the negative falls through — what #557's own summary asked for, "preserving
snapshot fallback for misses", refusing fallback only for hard failures like
ambiguity. The fall-through was #557's own design, never armed: the `| null`
return and the caller's `if (!payload) return null;` guard were unreachable.
This makes that dead guard live.

Measured on iPhone 17 (median of 9, warm daemon): predicate holds 0.14s / 0
snapshots, unchanged; predicate fails 0.25s / 1 snapshot. ~+0.11s on failing
assertions only.

Correctness gain beyond the envelope: the fast path evaluates a ONE-NODE tree, so
`visible` cannot see the ancestor geometry a list row inherits and its negative
can be wrong. Falling through re-asks the real tree and can turn a spurious
negative into a pass.

The #557 pin moved with its reasoning at the pin site.

* fix(layering): let a cutover row state a data-only admission retirement

Review blocker on #1883: R37 claimed `legacyRetirement.routeNames:
['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has
ever existed. They satisfied the non-empty shape check while proving nothing —
the vacuous registry claim AGENTS.md warns about, and a green gate that would
stay green if the deletion were reverted.

The cause was the model, not the row. Every `LegacyRetirementClaim` form names
something that must NOT exist, which a row can always satisfy by inventing a
name. `is` retired no module, route, or dispatch projection because it had none:
its legacy admission was a capability bucket plus membership in two static
platform command sets, so its real retirement is a DATA deletion the model could
not express. Rather than patch around that with sentinels or a per-command
policy file — both forbidden by the playbook — this generalizes the model.

`staticCommandSets` names the sets themselves and is proven from both sides:
each must still be DECLARED in production source, and must no longer list the
command. A fictional set fails the first half; a skipped deletion fails the
second. That is what an identifier-shaped claim cannot state.

R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the
deletion it actually performed.

Planted red, both halves, against the real gate:

  [R37 is-runtime-cutover] 2 violation(s):
    (is cutover row):1 — claims retired static command set
      'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares
    (is cutover row):1 — claims retired static command set
      'HARMONYOS_IS_SUPPORT', which no production source declares

  [R37 is-runtime-cutover] 2 violation(s):
    src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still
      admits is

so the exact claim that shipped is now rejected by name, and so is restoring the
membership it claims to have removed. Mechanism cases live with the other
planted-row tests; layering goes 177 -> 181.

* test(is): pin the exit-code guarantee independently of what answers the predicate

Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the
function the #557 reversal fixed. The reversal's guarantee must not evaporate
with it, so it gets a case that does not know how the daemon decided.

`is` is documented to "exit non-zero on failure". The reversal proved that at
the JSON envelope; nothing pinned it at the CLI boundary, which is where the
defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI
contract directly: a `predicate_failed` response exits 1 and never renders as
passed.

It survives the retirement untouched, because it asserts the outcome rather than
the path. Planted red with the exact pre-#1739 envelope the shortcut produced
(`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at
all — so the case fails, which is the regression it exists to catch.

Unpushed on purpose: the restack will carry it into the retirement cycle.

* refactor(is): retire the direct-iOS selector shortcut

thymikee's ruling (option b). `is` declares `device-runtime`, so its request path
must reach the device only through the operations R37 declares. It did not: a
simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector
without any capture, ordered after admission but not executing through the seam.

This is not retired because it was wrong. `wait` hypothesized that the degenerate
one-node evaluation mis-answers `is visible` for off-viewport nodes, traced it
through the code convincingly, then tested it on device and it did not reproduce
— XCUITest's own query is conservative about visibility, so the degenerate
evaluation never gets the chance. It is retired because it was an undeclared,
unmeasured bypass that made R37's singularExecution claim false: the same class
of untruth as the sentinel retirement names fixed in the previous commit.

Declaring querySelector as a real operation instead was rejected for a concrete
reason: offscreen-target-probe.ts consumes queryDirectIosSelector with a plain
session and cannot take a bound operation, so declaring it now would ship it
twice until Wave 5 moves the probe — the deferred-duplication shape that got
get's read deferral overruled. It returns as a declared, fact-admitted,
section 9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and
resolveDirectIosSelectorQuery — each had exactly one caller, all on this path —
plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports.
queryDirectIosSelector itself stays: the offscreen probe still consumes it and it
remains single-copy.

Latency cost, stated plainly and not softened: a held predicate on a simple iOS
selector goes from ~0.14s with no capture to ~0.25s with one, measured as the
median of 9 warm runs on iPhone 17. There is no fallback and no fast path.

R37's comment finally describes the code: "every predicate answers from the
resolved tree" was written while the shortcut existed. Its scope is now stated
too, so it is not read as absolute — the Android foreground-blocker diagnostic
still reaches adb on the failure path, where it cannot produce or change a
verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6
denominator work with R22's appState as its declared replacement.

Seven tests lost their subject. Those whose only content was the shortcut's own
mechanics are deleted; the outcome-level ones are retargeted and keep asserting
what survives.

---------

Co-authored-by: agent <agent@local>
… bug

An owner whose facts advertised `readTextAtPoint` but whose interactor cannot
perform it was reported as `{ status: 'unreadable', reason: 'surface-not-readable' }`.
That put a contract violation inside the closed reason set that licenses falling
back to the captured tree, so `get text` answered from potentially stale snapshot
text precisely because the runtime lied about itself. ADR 0019 §2 requires the
mismatch to fail as `runtime-contract-invalid`; it now throws.

Removing the only producer of `surface-not-readable` made that reason dead: no
path can reach it, since an interactor that HAS the read maps a blank or absent
answer to `no-text-at-point` via `elementTextRead`. Dropped from the union, its
consumer switch arm, and both test lists. `classifiedFallbackReason`'s `never`
arm stays — it is what makes adding a reason a compile error rather than a
silent untyped fallback.

Deduplication found while auditing the change:

- `invalidRuntimeContract` was module-private in `platform-runtime.ts`. It now
  owns its own module so both runtime modules share one construction. It is
  deliberately not exported through the platform facade: that facade must stay
  exhaustive over its sources, which would make this a public symbol with no
  external consumer.
- The 8-field runner execution projection was written out three times
  (`snapshot-runtime-capture-input.ts`, `interaction-read.ts`,
  `screenshot-runtime.ts`). One `runtimeExecutionFromContext` now serves all
  three; `screenshotExecutionFromContext` keeps its name and delegates, since
  `ScreenshotRuntimeExecution` and `SnapshotRuntimeExecution` are the same type.
  Dropping a field here silently strips request id, log/trace paths, XCUITest
  overrides, or runner lease context — an operation that still answers but runs
  unconfigured, which is exactly the defect the wait unit hit as a P1.

Red before green: with the old guard restored the new regression fails with
"Missing expected rejection" — the call resolves instead of throwing, which is
the silent degradation it exists to forbid.
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 20, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 7d3e5e6e48a4ae5e2f41945a5b8d892c7573acff: GTG. The advertised readTextAtPoint-without-implementation case now fails as runtime-contract-invalid, so it cannot silently fall back to stale capture data. get and read-only find share the bound read; the legacy read chain and direct-iOS get bypass are retired, with R36 owning the route and operation claims. Typed fallback coverage is non-vacuous, all exact-head checks are green, the PR is mergeable, and the +2.4 kB unpacked delta is within the review threshold.

@thymikee
thymikee merged commit 494eb52 into main Aug 20, 2026
28 checks passed
@thymikee
thymikee deleted the agent/wave4-get branch August 20, 2026 13:59
@github-actions

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