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
…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
… 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.
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