Skip to content

fix: derive iOS transition snapshots from visible presentation - #1831

Merged
thymikee merged 9 commits into
mainfrom
fix/private-ax-transition-settle
Aug 18, 2026
Merged

thymikee merged 9 commits into
mainfrom
fix/private-ax-transition-settle

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

Summary

Fix iOS interactive snapshots so private-AX transition state is derived from the visible presentation before semantic scoping.

This projects hidden/offscreen transition content out of the actionable tree, preserves scroll hints, promotes conservative app-provided navigation-title affordances, and distinguishes collapsed from expanded action shelves using viewport geometry. It also keeps raw iOS, macOS, and Android scope pushdown unchanged.

Stable capture now confirms broad screen replacements for 1.5 seconds, including replacements that begin after the first post-action capture or recover from tree to private AX. The confirmation applies only to XCTest captures using the default quiet period; explicit short quiet periods and non-transition captures retain their existing timing. This prevents --settle from accepting a self-consistent intermediate Element tree.

The change also hardens Apple runner source packaging and PR XCTest selection so the coordinate text-input policy exercised in source is included in the runner that ships and executes before merge.

Scope disclosure: 25 files are touched across four connected parts of the same iOS capture boundary: private-AX presentation and fixtures, scoped snapshot capture, stable-capture transition confirmation (including tree-to-private-AX recovery), and Apple runner packaging/PR test selection. No Android behavior, public CLI surface, docs, or skills changed.

Validation

  • Rebased on origin/main at 4bba40424 (fix(layering): keep the snapshot interactor seam out of the type cycle #1838); pushed head is 5b81b67e6.
  • pnpm check:affected --run: package, build, type, layering, lint, format, Fallow, metadata, and Node integration gates passed. Affected coverage passed 2,502/2,503 tests; the sole failure was an unchanged iOS recorder-provider test timing out in /bin/ps under concurrent host XCTest load.
  • The same recorder-provider scenario failed on plain origin/main under identical load (2/2 failures), including the same incomplete process-identity error, classifying it as baseline/host contention rather than a PR regression.
  • The coordinate input regression is selected in the iOS PR workflow. Pre-fix mutant evidence: ignoring isEnabled failed 1/1 at the new assertion; restored code passed 1/1 in 0.017 seconds.
  • Transition regression evidence: deterministic fixtures for both tree -> private-ax recovery and a replacement beginning after the first capture failed by settling on the transitional action file; the fix passes all 17 focused stable-capture tests. pnpm check:quick passes.
  • Exact-head unpublished package SHA-256: 2a1b036b14bab4cd8c692b85e0350db85820c6173b1355ac361db71ac505e04a; the installed daemon bundle matches the branch build byte-for-byte.
  • Packed-package clean install, publint, attw, dependency closure, all 13 published entry-point imports, and packaged CLI smoke passed using an isolated simulator device set.
  • Fresh iOS simulator clone: closing Element's composer settled in 1.79 seconds and removed hidden upload actions; opening it settled in 1.82 seconds and exposed the visible upload action.
  • Fresh iOS simulator clone: the room header settled in 2.12 seconds, exposed the app-provided room-details affordance, and navigated to the in-app Settings row.
  • Fresh iOS simulator clone on the follow-up head: dismissing Element's Threads notice settled in 1.863 seconds across five captures and emitted only the closed composer; all hidden attachment actions and the false close icon were absent.

@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
JS raw 2.30 MB 2.31 MB +4.4 kB
JS gzip 756.4 kB 757.7 kB +1.3 kB
npm tarball 878.0 kB 879.4 kB +1.4 kB
npm unpacked 3.07 MB 3.07 MB +4.6 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.3 ms 26.5 ms +0.2 ms
CLI --help 67.1 ms 67.8 ms +0.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +3.8 kB +1.1 kB
dist/src/script-source-bundle.js +666 B +225 B

@thymikee

Copy link
Copy Markdown
Member Author

P1 readiness blocker: testCoordinateTextInputCandidateMustBeEnabledAndContainTheTouchPoint covers new production Swift behavior but is absent from the iOS PR workflow’s explicit -only-testing: list. check:xctest-selection only rejects stale listed identifiers and Swift Runner Unit Compile only compiles the test, so this regression would first execute in the post-merge nightly. Add it to the targeted iOS PR lane and provide pre-fix red evidence. The ADR 0004 acquire/present route and simulator evidence otherwise look coherent. Also add the required 19-file/scope disclosure to the PR body; several exact-head lanes remain pending.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 1741c51: still not ready. testCoordinateTextInputCandidateMustBeEnabledAndContainTheTouchPoint remains absent from every PR-lane -only-testing: selector, so it compiles but does not run before merge. The body still lacks required touched-file/scope disclosure—now 24 files—and does not separately document the new stable-capture expansion/evidence. The branch is DIRTY/conflicted, so required CI has not run. The new broad-transition settling logic itself appears coherent and its regression is non-vacuous.

@thymikee
thymikee force-pushed the fix/private-ax-transition-settle branch from 1741c51 to e0b1dab Compare August 18, 2026 14:54
@thymikee

Copy link
Copy Markdown
Member Author

Addressed on rebased head e0b1dabfc: the coordinate text-input regression is now in the iOS PR lane’s explicit -only-testing: selection, with pre-fix mutant red (1/1 failed) and restored green (1/1 passed in 0.017s). The PR body now discloses all 25 touched files by scope, separately documents the stable-capture expansion/evidence, and records the sole local gate failure as host contention reproduced on plain origin/main. The branch is rebased on #1838 and fresh CI is running.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed current exact head 6f2e040f (the assigned 5b81b67e was superseded): code-clean and ready-for-human. The coordinate-candidate XCTest is now explicitly selected in the iOS PR lane with coherent selection/source guards; the conflict is resolved. The latest immutable pre-action baseline fix and targeted regression are sound, and the 25-file transition/XCTest scope is disclosed. Fresh exact-head CI is still running before merge. Minor body cleanup: refresh the stated pushed-head SHA from 5b81b67e.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 18, 2026
@thymikee
thymikee marked this pull request as ready for review August 18, 2026 15:48
@thymikee
thymikee merged commit f03c030 into main Aug 18, 2026
31 checks passed
@thymikee
thymikee deleted the fix/private-ax-transition-settle branch August 18, 2026 15:53
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-18 15:53 UTC

thymikee added a commit that referenced this pull request Oct 12, 2026
…face dismissal

The confirmation #1831 added for a modal dismissal also fired on ordinary navigation,
because it armed on any transition sharing under half its node identities with a
complete viewport projection - true of most tab switches and pushes. It was also
switched off by the requested window, so --settle-quiet 499 behaved differently from
500 and settleQuietMs: 150 callers (tester-army/e2e) never got the guard at all.

Arm on the transition the rule was built for: the pre-action tree shows a surface
presented over app content, which an interactive capture reports as the surface alone
(a handful of nodes, or a blocking prompt's own Alert root). Decided once, from the
first capture, never from the requested window; an explicit shorter window keeps the
confirmation on its own floor. Closes #3470.
thymikee added a commit that referenced this pull request Oct 13, 2026
…on alone

The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831
added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather
than of the transition in front of the loop. Three consequences, all reported as #3470:

- `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary
- a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the
  dismissal the guard exists for
- a shorter window read as "trust a coherent transitional tree sooner", which is not what
  `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to
  distrust

Arming now reads the transition only, and a confirmed replacement is held for the caller's own
window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action
projection or a tiny presented baseline, plus fewer than half the node identities carried over.

The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog
dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a
runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at
the default window on main — the same verdict at the same price, no longer window-dependent.
thymikee added a commit that referenced this pull request Oct 13, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 13, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 13, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 13, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 13, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 13, 2026
…ed quiet window (#3512)

* fix(settle): decide the broad-transition confirmation by the transition alone

The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831
added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather
than of the transition in front of the loop. Three consequences, all reported as #3470:

- `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary
- a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the
  dismissal the guard exists for
- a shorter window read as "trust a coherent transitional tree sooner", which is not what
  `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to
  distrust

Arming now reads the transition only, and a confirmed replacement is held for the caller's own
window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action
projection or a tiny presented baseline, plus fewer than half the node identities carried over.

The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog
dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a
runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at
the default window on main — the same verdict at the same price, no longer window-dependent.

* fix(settle): sample an extended confirmation on its own cadence

Poll cadence derived from the requested window once, before the loop ran, so the 1.5s confirmation
this branch extends that window to was spent sampling at the caller's grid. At `--settle-quiet 25`
that is 109 capture round trips to reach a verdict the default grid reaches in 10 — a confirmation
whose cost is 11x the reads it needs, on the tightest windows where settle is asked for at all.

Cadence now follows the window in force. Both directions hold: a tight window still gets a grid that
can measure it, and an extended wait gets the default grid rather than the caller's. Pinned by the
capture count in the 25ms case: 10 here, 109 with the recompute removed (mutation-checked).
thymikee added a commit that referenced this pull request Oct 13, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 13, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 13, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 13, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 13, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 13, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 14, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 14, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 14, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
…ttle (#3577)

* feat(ios): carry the presented-surface fact from capture producers to consumers

A settle confirmation that has to prove a dismissal (#1831) has nothing to compare
against: the capture contract says nothing about whether a modal presentation was over
the app, so a consumer can only infer it from tree shape — and a tree shape that happens
to hold before a tap is exactly what makes the confirmation arm on a no-op. iOS is where
it matters most, because there an overlay is usually the same app process, so it moves
neither the surface identity nor the foreground-owner fact.

Publish the producer's own reading instead, on the state that is already built from the
capture's unfiltered nodes:

- `SnapshotState.presentedSurface` takes 'presented' | 'absent' | 'unknown', and
  `readPresentedSurfaceFact` is the one wire reader. A producer that inspected its whole
  subject and saw nothing presenting says 'absent'; one whose evidence cannot answer says
  'unknown'; only a producer that never classified stays silent. Anything the vocabulary
  cannot name reads as 'unknown', so an unrecognised value can never become a false
  absence.
- The fact rides the response envelope on every iOS capture route, including a digest, and
  travels with the nodes wherever a capture crosses a layer: the backend capture and
  interactor snapshot results, the daemon capture cache, the interactive-retry and
  deferred-re-observation nodes, the ref-target frame, and the settle baseline. A consumer
  that adopts an interaction's own capture therefore keeps the fact that capture
  established rather than re-deriving a weaker one.
- Both settle routes mint that baseline from one function, which moves to capture-kit
  beside the assembly that builds the state it reads: the interaction route lives in
  `commands/` and the generic route in `daemon/`, and the layering forbids the second from
  importing the first. So the next fact a baseline owes cannot be carried by one route and
  dropped by the other.
- The runner forwards the reading of the tier that answered. Its accessibility tree carries
  no window classes, so it states 'presented' from a resolved blocking system modal and
  never 'absent'; the shared classifier comes next.

Consumers are untouched: nothing decides on the fact yet.

* fix(kernel): keep the presented-surface read off the shared wire reader's closure

Review of #3577 found four problems with how the fact crosses the wire.

`readPresentedSurfaceFact` validated against `PRESENTED_SURFACE_STATES`, which made every eager
consumer of `kernel/record` evaluate `kernel/snapshot.ts` as well. ADR 0019's loading-shape probe
measures exactly that: thirteen carried entries grew by one module and 70 eager statements, which
`scripts/__tests__/eager-closure-budgets.test.ts` refuses — the growth is a pre-existing module made
eager, so no split explains it and shrinkage elsewhere cannot fund it. The reader now decides from a
table it owns, keyed by `PresentedSurface`, so a vocabulary state without a row fails to compile
rather than reading as `unknown`. The lookup is own-property, which also closes the inherited-member
path an `in` test would have opened: a payload naming `constructor` is not a state.

`PRESENTED_SURFACE_STATES` is frozen. `as const` was only a type-level promise on an array the reader
trusted payloads against, so any consumer could move the boundary between a state and `unknown` for
the whole process.

The client normalized the fact on the default response path and returned a digest payload verbatim,
which meant the same field was read two ways depending on `--response-level`. Passing a leveled
payload through without reshaping it is not a licence to skip the fact's own vocabulary, so the
digest path reads it through the same reader; a payload with no key stays byte-identical.

Two doc comments claimed a capture "came from read" something. Both now name the producer that did
the reading.

* refactor(interaction): hand find's leaf the baseline surfaceScopedNodes minted

PreresolvedInteractionTarget carries one SurfaceScopedNodes minted from
find's own capture instead of mirroring nodes, surface and presentation
field by field, so the adopting leaf's settle baseline is that value
as-is. PresentedSurface becomes a plain union, and the fact travels
only inside SnapshotState: the top-level BackendSnapshotResult field had
no production writer. Moves the moving-target gate onto the capture-kit
baseline mint.

* fix(daemon): carry the presented-surface fact on the rest-proof baseline

The moving-target rest tree copied the baseline shape by hand and left
out presentedSurface, so the baseline after a rest proof differed from
every other one. It now carries the same facts surfaceScopedNodes mints;
it stays local because the snapshot-baseline import would grow the eager
closure of most daemon entries.
thymikee added a commit that referenced this pull request Oct 14, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 14, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 14, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
… on screen replacement

DRAFT / BLOCKED BY #3565 — do not merge. The rule below reads a property of the baseline CAPTURE,
and #3565 replaces it with a fact stamped by the capture producer. Kept as the reviewed starting
point and the device evidence, not as a shippable rule.

The layer under this one arms on a complete-viewport post-action projection plus a <50% identity
overlap. A tab switch and a push are exactly that: both screens project completely and the rows of
one tab are not the rows of the next, so ordinary navigation pays the 1.5s floor (#3470's whole
complaint). This narrows the trigger to the pre-action tree showing a surface presented over app
content, which an interactive capture reports as the surface alone: a presented pageSheet projects
4 nodes, a system permission prompt 5 nodes under its own Alert root, while an ordinary screen of
the same app projects 24+ including its nav or tab bar. Whether the surface was actually replaced
stays per-capture.

Why it is still a draft: on device the same automation sheet projects 4 nodes through an interactive
capture and 6 through a full one, so arming survives only on the route whose baseline happens to be
the interactive flavour. A dismissal preceded by a plain `snapshot`, or taken on the generic `back`
route, loses the #1831 coverage this rule exists to keep. The chrome heuristic was considered and
rejected: it regresses modals that carry their own nav bar against `main`. The durable rule is a
typed `presentedSurface` fact from the capture producer (#3565); reading it must keep this parent's
arming when the producer answers 'unknown', and the count bound and the Alert special case both go
with `showsPresentedSurface`.

The three navigation cases are mutation-checked against the parent's arming: restoring the
viewport-completeness arm fails exactly those three and nothing else.
thymikee added a commit that referenced this pull request Oct 14, 2026
…n, and pin the parity gap

The review on this PR found that padding the provider-scenario dialog past the
arming rule's size bound (SHARED_SCREEN_NODES) silently removed the only test
that checked the 1.5s confirmation through the daemon: the transition stopped
being broad (4 of 6 identities survive), so `waitedMs >= 1_500` had to become
`captures in 2..3`. Revert that padding, which restores the unpadded dialog and
its original assertion; all four provider-scenario cases pass on this rule.

Add the parity case the review asked for: the same presented sheet dismissed
from an interactive baseline (4 nodes, as `snapshot -i` projects it) and from a
full-tree read of the same screen (6 nodes), both asserted to confirm. Measured
on the injected clock, the interactive baseline confirms for 1,502ms and the
full-tree baseline for 27ms with `settled: true` either way — #1831's guard lost
on the route whose baseline happens to be the richer flavour. Marked `fails`, so
the lane stays green and the case turns red ("expected to fail but passed") the
moment #3565's `presentedSurface` replaces the count bound: that red is the
signal to drop the marker, and the assertion is the rewrite's acceptance test.
thymikee added a commit that referenced this pull request Oct 14, 2026
…ays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.
thymikee added a commit that referenced this pull request Oct 14, 2026
…tles (#3522)

* fix(settle): resolve the default quiet window from the capture that pays it

`--settle` asked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge
answers well inside it, so the loop spends a whole extra capture holding a window the tree has
already satisfied — and the window is paid twice per settled command: once as the settle's final
window, then again as the next command's baseline capture.

The loop now prices the window from the capture it performs rather than the one the action was
performed against: an unrequested `--settle` resolves 300 ms when that read came from the simulator
snapshot bridge, and 500 ms for the Apple runner's own tree read, the Android helper, and any
capture that declares no provenance — only the bridge carries the measurement. It re-prices on every
capture, so a mid-run fallback to a more expensive read widens the remaining wait, and it reports
the window it ran on (`settle.quietMs`) so the response states which default it applied.

Keying on the baseline's producer was the first cut and is wrong: the window governs the loop's own
reads, and a stored ref frame or session tree can be several commands stale or served by another
producer. Keying on the channel would be wrong too — the bridge and the Apple runner share `xctest`
and read at different costs. `--settle-quiet` still outranks both, and `wait stable` keeps the
general default of its own.

Cadence follows the window in force, so the shorter default also samples on a grid that can measure
it. The loop's window, quiet clock, and recovery budget moved to one owner each in the same pass,
which is what makes the re-pricing a single line rather than a fourth piece of loop state.

The trap this layer owes the layer under it is pinned by name: a 300 ms default sits under the 1.5 s
confirmation floor, so the arming rule that layer removed must not come back. Mutation check: with
`requestedQuietMs >= DEFAULT_STABLE_QUIET_MS` restored in the arming path, that case is the only one
in the file that fails — i.e. the shorter default would have silently switched the #1831 guard off
for every default settle the bridge answers.

* fix(settle): re-check broad-transition arming on every capture until one arms

The arming decision was made on the first capture and then stored as null for the rest of the
run, but two of its inputs belong to the capture, not to the run: viewport-root completeness and
the declared capture channel. A dismissal of anything larger than an alert or sheet needs both
before the confirmation may arm, so a mid-animation rootless or empty first read -- or one served
by another channel -- ruled the 1.5s floor out for a settle that owed it. Only the pre-action
projection is constant for the run, so it is the only input allowed to rule the confirmation out;
the per-capture half now re-asks until one capture arms.

* fix(settle): hold the quiet window in the loop that re-prices it

The window helper had one call site and one reader per side of it, so the
loop holds the window directly: an unrequested window is re-priced from
each capture it performs, a requested one stays fixed. Behaviour is
unchanged; the priced value the result reports is the same window.

* fix(settle): price the quiet window per producer and report it on every path

- Price the default quiet window from an exhaustive per-producer table, so a new producer must
  state its window.
- Return capture failures from the stable-capture loop with the window the run priced, so a broken
  settle reports 300 ms after a bridge read instead of the general default.
- Extract the per-capture window step out of runStableCaptureLoop.
- Pin the shortened bridge wait on a non-broad fixture, and pin that the deciding capture prices the
  whole quiet span across a producer switch.
- State the confirmation gate and test rationale as invariants.

* fix(wait): surface a stable-capture failure from wait stable

The stable-capture loop returns a capture failure instead of throwing it. `wait stable` now rethrows
that failure rather than mapping it to a stable timeout. The arming-input test comment now says a
mid-animation read that misses either input leaves arming open.

* test(settle): rewrap the arming-input comment to the column width
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