refactor: migrate is to the request-bound device runtime - #1883
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed unit head
|
Behaviour-neutral. No descriptor changes platform execution, the cutover table is untouched, and no contract surface is added. - buildRuntimeCaptureInput moves to its own module so every request-bound capture consumer builds CaptureSnapshotInput one way. - The admit-then-bind sequence in the snapshot/diff resolver becomes one named step, ready for the selector units' second caller. - handlers/find.ts splits into focused target-capture and match-resolution concepts (600 -> 346 lines); behaviour unchanged.
`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.
|
Re-reviewed exact head
The branch is also stacked on currently DIRTY #1877; Linux and iOS Smoke are still running. |
4778a27 to
8aed44f
Compare
`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.
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. #557's perf property is preserved and separable: it bought a snapshot-free 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.
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.
37d0b9c to
05e6d88
Compare
8aed44f to
52a1c9d
Compare
`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.
|
Blocking on exact |
Summary
Important
This PR deliberately reverses a merged decision (#557), on thymikee's explicit instruction.
ison a simple iOS selector reported a failing assertion as a successful command. SeeReversal of #557 below — it is not incidental
to the migration and should be reviewed on its own terms.
Wave 4 / ADR 0019 unit: migrates the canonical
isdescriptor offplatformExecution: LEGACY_PLATFORM_EXECUTIONonto the request-bound device runtime. Cutover rule R37.Stacked base. This branch is stacked on
agent/wave4-getat4778a27512ee2e1c8e8c0efb7833ee4da908f792(itself on #187630435df1b, itself onorigin/main8c06965d2). It consumes the selector capture seam that unit owns —resolveBoundSelectorCapture,createBoundSelectorRuntime,admitAndBindSnapshotCapture,CaptureSnapshotInput.signal, and theselectorCaptureFixture/makeIosAppSessionfixtures — and restructures none ofselector-capture-binding.ts,selector-capture-runtime.ts, orsnapshot-runtime-binding.ts. Review after #1877.This unit is not the last selector unit.
find's cutover is deferred behind the Wave 5focus/typesurfaces, socreateSelectorRuntimeand itsrequireCommandSupportedcall survive with a'find'-only union,captureDatastays optional oncaptureSnapshot, andcaptureSnapshotWithInteractorstays.isanswers all seven predicates (visible,hidden,exists,editable,selected,focused,text) from the resolved capture —isCommandnever reachesbackend.readText— so its whole platform execution is the request-bound capture. It declares the sharedselectorCaptureRuntimePlanUses, inspects exact owner facts once, refuses before binding, and binds exactly once. The use'spreferred: ['readTextAtPoint']member isget's;isbinds it because the seam is shared and one binding serves the family, and never invokes it — so R37'ssingularExecutionclaims only the two capture operations, andreadTextAtPointstays R36's alone.Admission moves ahead of the direct-iOS fast path
dispatchIsViaRuntimepreviously randispatchDirectIosSelectorIsbefore it built any runtime. Onceisdeclaresdevice-runtime, ADR 0019 §6 requires resolve → admit → bind before anything in the request path reaches the device, so admission now runs first for every target shape and that query is a fast path within an admitted request. This is the same blocker #1877 was held on (9056a4c79), taken by construction here rather than rediscovered in review.The rule is now documented once, on
createBoundSelectorRuntime, replacing the two duplicated six-line call-site commentsgetandiswere each carrying. A future selector unit that adds a shortcut inherits it at the construction point.queryDirectIosSelectoritself is not claimed by this unit: it is co-owned byget,wait, and the Wave 5 offscreen-target probe, so under §6 it stays physically in place and remains named shared debt. This PR orders it; it does not migrate it.Declared behaviour change: refusing beats displacing-and-lying
istakes the active-app plan split, so the facts decide per family rather than a hand-written iOS branch.On iOS,
appBundleIdis 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 three-node blank screen. Verified A/B on device this wave: with Settings foregrounded, a sessionlessfind "General" listreturnedsuccess: true, matches: []and left Settings displaced. On Android the same no-app state captures the real launcher correctly, and the platform facts already encode that asymmetry at their declaration sites —packages/platform-apple/src/runtime.ts:349setswithoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, whilepackages/platform-android/src/runtime.ts:144marks it available for every non-synthetic kind.So an iOS
isagainst a session with no tracked app is now a typedSESSION_NOT_FOUNDrefusal carrying theopenhint, instead of a confident false answer about the runner's screen. This is a correctness fix, not a narrowing. Android, HarmonyOS, Linux, Web, and macOS proceed exactly as before.Deliberately not claimed: the older rationale that such a capture "cannot succeed". That is too strong — capture works fine when SpringBoard is named as the session app. The real constraint is that XCUITest needs some bundle id, and the argument for refusing is that refusing beats displacing-and-lying.
Parity deltas (two, and nothing else)
Legacy denominator, computed not recalled:
isCommandSupportedOnDevice('is', …)over the exhaustive 6-family × 6-AppleOS × 4-kind matrix (44 rows) admitted 19 cells — Applesimulator+deviceon ios/ipados/tvos/macos/visionos/watchos (12), Androiddevice/emulator/unknown(3), HarmonyOSemulator/device(2), Linuxdevice(1), Webdevice(1); Vega none.snapshot(refactor: migrate snapshot to device runtime #1779),diff(refactor: migrate diff to request-bound runtime #1847), andget(refactor: migrate get to the request-bound device runtime #1877) already landed.Every other cell keeps its outcome. Fact denominator after cutover —
captureSnapshot: Applesimulator/deviceon ios/ipados/tvos/macos/visionos available, watchOS unavailable, other kinds unavailable; Androiddevice/emulator/unknownavailable, syntheticsimulatorunavailable; HarmonyOSemulator/deviceavailable; Linuxdevice; Webdevice; Vega unavailable everywhere.captureSnapshotWithoutActiveApp: unavailable for the whole Apple iOS family (ios/ipados/tvos/visionos/watchos), mirrorscaptureon macOS, otherwise as above. Providers — WebDriver available for both only when the owner advertises snapshot support, exposes an interactor, and the device is a mobiledevice-kind Android oriosApple target (webDriverSnapshotDevice); Limrun available live,liveSessionUnavailablerecovery-only. Provider ownership fails closed and never borrows a local family capture.Retirement list
iscapability bucket (capability: ALL_DEVICE_COMMAND_CAPABILITY) on the descriptor;is— thecapability: 'is'arm ofcreateSelectorRuntimeand therequireCommandSupportedcall it reached; the union narrows'find' | 'is'→'find';'is'fromHARMONYOS_SUPPORTED_COMMANDSand fromWEB_QUERY_COMMANDSinsrc/core/capabilities.ts;predicate as IsPredicatecasts and a single-fieldsplitwrapper object indispatchIsViaRuntime—checkIsArgsalready returnspredicate: IsPredicate, so both casts asserted what the type system had;getandiscall sites, collapsed to one doc oncreateBoundSelectorRuntime;isentries in the legacy-matrix projection assertions (capabilities.test.tsmacOS/Linux/web lists; the HarmonyOS static-capability list incapability-plugin-routing-parity.test.ts), following that file's own stated convention that a runtime-backed command's admission is proven from exact device facts, never through the legacy matrix projection;'is'and the already-dead'get'fromHARMONYOS_SUPPORTED_COMMANDS_REF, unreachable once the command leavesBASE_COMMAND_CAPABILITY_MATRIX(the(b.1)oracle iterates matrix keys);scripts/layering/check.tsthat stopped at R33 and already omitted viewport R34 and get R36;createSelectorRuntimeitself, with itsrequireCommandSupportedcall and its now-unused import. On the restacked basegetmoved read-onlyfind's leg onto the bound seam, leavingisas its only caller — so migratingismade it fully unconsumed, and leaving it would have takencheck:production-exports19 → 20. This is the deletion the original wave plan assigned toisand that the mid-wavefinddeferral had suspended;get's read-leg move restored the condition. No selector command can now reach the device on a capability bucket, even by mistake.Made live rather than retired:
buildDirectIosIsResult's unreachable| nullreturn and its caller's deadif (!payload) return null;guard, which #557 wrote for a fall-through it never armed.ishad no legacy adapter module and nodispatchprojection of its own, so there is none to delete.ALL_DEVICE_COMMAND_CAPABILITYandLEGACY_PLATFORM_EXECUTIONboth keep other consumers.Reversal of #557: a failing assertion now fails
Carried on thymikee's explicit instruction; my own recommendation had been to report it and leave it.
On the assigned
iPhone 17, exercising the false paths surfaced this:isis an assertion.website/docs/docs/commands.mdstates the contract under Assertions: "isevaluates UI predicates against a selector expression and exits non-zero on failure." The direct-iOS arm broke it —{"success": true, "data": {"pass": false}}reachesisCliOutput, which rendersPassed: is <predicate>without ever readingpass. A failing assertion reported as success is the one thing an assertion command must never do, and in a replay script it means execution continues past a broken state.After:
--jsongivessuccess: false,error.code: COMMAND_FAILED,details.reason: "predicate_failed", exit 1. ThePassed:renderer needed no patch: once a negative can no longer produce a success envelope, it is correct by construction.Which direction, and why it is not just "the older behaviour won". The two paths disagreed, and making them agree could have gone either way. The argument for the other direction is real — for an agent, "I asked a question and got an answer" beats "a false predicate is a command failure". I chose to make direct-iOS match the capture path because it is the documented contract rather than merely the incumbent one, and because the alternative is a different and much larger change: flipping
isto a zero-exit predicate reporter would alter the contract on every platform and path, break every script and CI job that relies onisfailing the shell, and require its own PR, docs, changelog, and probably a major version. It is also already the behaviour ofis hidden(never handled by the fast path) and ofis existsend to end. If the project does want the answer-shaped contract, that is a deliberate product change to make once, everywhere — not something to arrive at by leaving one iOS arm inconsistent.#557's performance property is preserved, and separable. #557 bought a snapshot-free passing assertion on the replay hot path. That is untouched: a predicate that holds still answers from the runner query with zero captures, pinned by
a direct-iOS predicate that holds still short-circuits without a capture. Only the negative falls through — which is what #557's own summary asked for, "preserving snapshot fallback for misses", refusing fallback only for hard failures like ambiguous matches. A predicate the one-node fast path could not establish is a miss, not a hard failure.The fall-through was in fact #557's own design, never armed:
buildDirectIosIsResultwas already typedRecord<string, unknown> | nullbehind a caller guardif (!payload) return null;that nothing could reach. This PR makes that dead guard live.Measured cost (median of 9 runs each, warm daemon,
iPhone 17):So ~+0.11 s on failing assertions only, and nothing on the path #557 optimised. A replay that fails an assertion is a replay that is about to stop.
There is a correctness gain beyond the envelope: the fast path evaluates against a one-node tree (
nodes: [node]), sovisiblecannot consult the ancestor geometry a list row inherits, and its negative can simply be wrong. Falling through re-asks the real captured tree, which can legitimately turn a spurious negative into a pass — pinned bya fast-path negative that the real tree contradicts resolves as a pass, not a failure.The #557 pin itself moved with its reasoning at the pin site (
src/daemon/handlers/__tests__/interaction.test.ts), renamed tois simple iOS selector falls through to the snapshot when the runner predicate fails, so a future reader finds out why the pinned value changed where it is pinned rather than only in this description.Retirement of the direct-iOS
isshortcut (thymikee's ruling)A simple iOS
id=/label=target was answered by a direct XCUITestquerySelectorwith no capture — ordered after admission, but not executing through the seam. It is gone. Every predicate now resolves through the bound capture.Not retired because it was wrong.
waithypothesized that the degenerate one-node evaluation mis-answersis visiblefor 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'ssingularExecutionclaim false: the same class of untruth as the sentinel retirement names fixed one commit earlier.Declaring
querySelectoras a real operation instead was rejected concretely:offscreen-target-probe.tsconsumesqueryDirectIosSelectorwith a plain session and cannot take a bound operation, so declaring it now would ship it twice until Wave 5 moves the probe. It returns as a declared, fact-admitted, §9-measured operation in the unit that also moves the probe.Retired:
dispatchDirectIosSelectorIs, its call site,buildDirectIosIsResult,resolveDirectIosSelectorQuery— each with exactly one caller, all on this path — plus theResolvedDirectIosSelectorQuerytype they orphaned and two imports.queryDirectIosSelectorstays: the offscreen probe consumes it and it remains single-copy. Net −104 lines inselector-runtime.ts.Two costs, both declared, neither softened.
Latency. A held predicate on a simple iOS selector goes from ~0.14 s with no capture to ~0.25 s with one (median of 9 warm runs,
iPhone 17). No fallback, no fast path.is textnow reads the same source asget text. This one is user-visible and was found on device, not predicted. The shortcut compared against the runner'squerySelectortext; the capture path compares against the resolved tree. On the same element they differ:So
is textandget textdisagreed about the identical selector, and now agree. A script asserting the runner's narrower value will start failing; the fix is to assert the valueget textreports. Reported as a behaviour change rather than absorbed, because the tree text is what R37 declaresisanswers from.Why the exit-code guarantee lives at the CLI layer
The retirement deletes
buildDirectIosIsResult— the exact function the#557reversal (commit 2) fixed. Commit 2's production change is therefore subsumed by this one, and its guarantee is strengthened rather than weakened: a failing assertion used to fail because of a guard inside the shortcut, and now fails structurally because the bound capture is the only thing that answers a predicate andisCommandraisesCOMMAND_FAILED.That is exactly why the guarantee needed a test that does not live beside the daemon coverage.
src/__tests__/cli-exit-paths.test.tsstubs apredicate_failedresponse and asserts the process exits1and never rendersPassed— it does not know, and cannot know, which internal path decided. Its planted red is the pre-#1739envelope the shortcut produced ({ok: true, data: {pass: false}}), which yieldsexitSpy.calls === []— no exit call at all — the precise regression it exists to catch. The daemon half (is visible fails for nodes outside the current viewport) survives untouched at its own layer.A reviewer seeing commit 2 fix a function commit 5 deletes should read them together: the decision was taken deliberately and is recorded, and the behaviour it established is now impossible to lose without this CLI case going red.
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.
Validation
Red before green, captured by full revert rather than
git checkout <base> -- <path>(which leaves newly added files on disk and contaminates the red), and re-proven after the comment relocation refactor. All six new route regressions failed against pre-change code:The two #557-reversal regressions were proved red separately, against the post-migration code:
The CLI change is proved on device, not only in JSON: exit code
0→1for the same failing assertion (see the reversal section above).Tests bind at
inspectFacts/bindDevice, never atcore/dispatch.ts. The nine existingiscases ininteraction.test.tsalready reached the runtime seam throughgetRuntimeBindings()and stay green unchanged.Live device — iOS Simulator
iPhone 17(1604B975-D6CD-41D7-ABBD-BE95F0796F90), afterpnpm build && pnpm clean:daemon, sessions closed afterwards (session list→[]):is exists id=com.apple.settings.primaryAppleAccountPassed: is existsis visible id=…Passed: is visibleis text id=… "Apple Account"Passed: is textis hidden id=…(false path)COMMAND_FAILED: is hidden failed … actual={"visible":true,"editable":false,"selected":false,"focused":false}is visible label="DefinitelyNotOnThisScreen"(not-found)COMMAND_FAILED: Selector did not matchis visible label="General"(ambiguous)AMBIGUOUS_MATCH: selector matched multiple elementsis visibleon an iOS session with no tracked appSESSION_NOT_FOUND: iOS is requires an active app session on the target device. Run open first (for example: open --session isw4noapp --platform ios --device "<name>" <app>).The refusal did not displace anything — the Settings session in the adjacent worktree session remained foregrounded and intact.
Android, HarmonyOS, Linux, Web, and every provider row are covered by fact/parity evidence only and are stated as residual live risk rather than claimed.
Gates.
pnpm format:check,pnpm lint,pnpm typecheck(the fulltsc -bpackage graph plus both project configs, not a narrowertsc -p tsconfig.json),pnpm check:layering(176/176 node tests plus the guard; the migrated-command list now reads… snapshot, diff, get, is, viewport), andpnpm check:affected --run.pnpm check:production-exportsis 19, unchanged from the base and frommain.Accounting
Production source versus stack base
4778a2751, measured over git-tracked paths only so emitted.d.tscannot contaminate the figure:src/TypeScript: +1,341 B, against a posted budget of "must decrease". It is entirely prose the reviewers asked for: +623 B relocating the admit-before-shortcut rule ontocreateBoundSelectorRuntime(replacing two duplicated call-site comments), and +1,196 B documenting the perf: speed up iOS replay runner #557 reversal atbuildDirectIosIsResultand at the pin site, as instructed. Code-only movement is net negative —capabilities.ts−14 B,registry.ts−8 B, andselector-runtime.ts's own logic shrank before its doc block. thymikee accepted the size result on CI Size authority and directed me not to chase the byte figure; the operative bar from docs: keep size review in CI and local feedback fast #1842 is npm unpacked >3 kB or ~700 net production lines, and this is far under both.Packaged versus stack base (all four negative, 313 files both sides):
Both sides were measured with the built helper artifacts present; a later re-measurement against a fresh build without them produced meaningless tarball/unpacked rows, which is the documented local caveat. The CI Size workflow is authoritative —
docs/agents/adr-0019-unit.mdon currentmain(after #1842) says not to run a local size comparison by default. My stack base predates #1842, whoseadr-0019-unit.mdstill prescribespnpm size --compareagainst the base build; that cost nothing here because the base build was needed for device verification anyway.Duplication unchanged (
fallow audit: dead code 0, duplication 0, one pre-existing complexity warning on a test arrow ininteraction.test.ts). Daemon-server → platforms unchanged at 62 total / 43 value edges, zero value cycles, zero spine back-edges.Tradeoffs and known gaps
packages/contracts/src/interaction-guarantees.tsclassifies zero observation commands and its guarantee vocabulary is entirely tap semantics, so anisrow would be all-inapplicable— a row that satisfies the completeness gate while asserting nothing. Carried at the descriptor level instead.05e6d8830). It claimedrouteNames: ['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']— identifiers that have never existed, satisfying the non-empty shape check while proving nothing. The cause was the model: everyLegacyRetirementClaimform names something that must NOT exist, which a row can always satisfy by inventing a name.isretired no module, route, or dispatch projection because it had none, so its real retirement is a data deletion the model could not express. Rather than sentinels or a per-command policy file (both forbidden), the model gainedstaticCommandSets, proven from both sides: each named set must still be declared in production source and must no longer list the command. Planted red against the real gate — the exact claim that shipped is now rejected by name, and so is restoring the membership it claims to have removed. Layering 177 → 181 tests.ishas no@reftarget form. Its only target is a selector expression (checkIsArgs→splitIsSelectorArgs);is visible @e23isINVALID_ARGS: is requires a selector expression. Recorded because the unit brief assumed otherwise and my start record's evidence list over-claimed "selector and@reftargeting".skills/**are unchanged: CLI grammar, help, and user-visible output are unchanged apart from the declared iOS no-app refusal, which is an error path with its own typed hint rather than a documented surface.Touched files, this unit's own commit
dd6c8f531: 10 — 3 productionsrc/, 2scripts/layering/, 4 existing test files, 1 new test file (+306 / -35). GitHub's diff againstmainshows 63 files /+2246 / -475because the branch is stacked: it carries #1876 and #1877 underneath. Review this unit's commit, or diff against4778a2751.Scope did not expand beyond the unit; the only adjacent edits are the tightening pass listed above.