Repository navigation
fix(settle): resolve the default quiet window from the capture it settles - #3522
Conversation
Size Report
Startup median (7 runs, lower is better):
|
There was a problem hiding this comment.
All reported issues were addressed across 9 files
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
6ad9bf3 to
9bf62c7
Compare
|
The settle change looks correct at 6ad9bf3, and CI is green, including Smoke Tests, with no conflicts. Before merge, please decide who owns the quiet-window fact and fix the 300 ms scope text so it names the simulator snapshot bridge. This PR is also stacked on #3512, which should land first. Not blocking, and you can take or leave these: (1) the window is keyed on the baseline's producer at https://github.com/callstack/agent-device/blob/6ad9bf3/src/commands/interaction/runtime/settle.ts#L175, but the loop compares the loop's own captures. The baseline can come from an earlier stored snapshot, and the bridge can fall back to the Apple runner mid-session. A bridge baseline followed by runner captures then gets 300 ms, and a runner baseline followed by bridge captures misses the speedup. The rule should be that the default window follows the source of the captures being compared. (2) Help, grammar and client docs say 300 ms applies to an iOS simulator or healthy iOS interactive tree (https://github.com/callstack/agent-device/blob/6ad9bf3/packages/command-registry/src/flag-definitions-action.ts#L214), but the code applies it only to the simulator snapshot bridge. A simulator capture served by the Apple runner keeps 500 ms. Deriving that text from the same constant would keep it honest. (3) The roughly 17-line benchmark comment on SIMULATOR_BRIDGE_SETTLE_QUIET_MS could shrink to two lines, with the measurements moved to docs/evidence. (4) The two new scroll --settle cases in generic-settle.test.ts use the real settle clock, about 900 ms of sleeps. An injected clock would match settle-default-quiet.test.ts. Could settleAfterAction pass quietMs as optional, so that runStableCaptureLoop resolves the default from the first capture's provenance? The 300 ms value would sit beside the producer table in ios-snapshot-acquisition.ts, or be exported from platform-apple, instead of a literal in shared runtime. That would remove SurfaceScopedNodes.captureSource, both writers and the generic-route threading, and the loop could be tested directly. One owner would cover both routes and follow the source actually read, including after runner fallback. pollMs would then be derived after the first capture, as it already is from quietMs. Nothing blocks this, though the quietMs and pollMs setup would become lazy. The open inline threads on the 300 ms wording at #3522 (comment), #3522 (comment) and #3522 (comment), and on the baseline-keyed window at #3522 (comment), still stand. The two lower-priority threads on the benchmark comment and the real-clock tests (#3522 (comment) and #3522 (comment)) also still apply. The first two are marked resolved, but the text is unchanged at this commit. I did not rerun the live iPhone 17 A/B or the affected-checks run, so the PR body's measurements are unverified here. I also did not exercise a mid-loop fallback to the runner. Point 1 comes from reading the fallback paths in snapshot-route.ts. This review covers 6ad9bf3. The branch has since moved to 9bf62c7, a rebase onto the updated #3512 that only renames a test in the base. I will confirm the review against the new head on the next pass. |
There was a problem hiding this comment.
All reported issues were addressed across 10 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
856999e to
2bcaa2b
Compare
thymikee
left a comment
There was a problem hiding this comment.
Fixed in 6f43f54e6, which the run predates — the block ran from 17 lines of benchmark data down to a rule statement, and no device, timing, or run count remains in the source:
/**
* The default quiet window a capture earns. The iOS simulator snapshot bridge answers well inside
* the general default, so holding the general window on that read spends a whole extra capture on a
* window its tree had already satisfied. Keyed on the producer, not the channel: the bridge and the
* Apple runner share the `xctest` channel and read at different costs, and only the bridge carries
* the measurement. Every other read — the runner's own tree read, the Android helper, a capture that
* declares no provenance — keeps {@link DEFAULT_STABLE_QUIET_MS}.
* (PR #3522 holds the interleaved before/after medians and the late-diff accuracy check.)
*/I kept the numbers in this PR's description rather than opening a file under docs/evidence/: that directory's existing entries are exact-head sweeps with raw artifacts, digests, and named corpus tags, and this is a six-sample manual A/B on one simulator. Pointing at the PR number does rot once this merges, so the pointer stays a parenthetical rather than a {@link} anyone is meant to resolve.
5e5842a to
88fea6f
Compare
|
The code in 88fea6f looks right to me, but I would like the latch commit covered by one live run before merge. The CI checks are green (14 reported, none failing), and there are no conflicts. The live simulator runs in the PR body predate the latch commit, so they do not show it working. Please run one live simulator settle that reaches a confirmed dismissal, then keeps polling on the 300 ms bridge window. The output should show the quiet window staying at 300 ms for the rest of the run. I read the code and the test fixtures by hand. I did not run the mutation checks, and I did not read every line of the fake-clock wiring in generic-settle.test.ts. Not blocking: Could the quiet window be one exhaustive Of the eight bot threads, all eight look fixed at this head, so you can resolve them: r4250825442, r4250825462, r4250825469, r4250825474, r4250825481, r4250825483, r4251997139 and r4251997145. Before merge, #3512 (layer 1) must land first, including its decision on the presented-surface fact, and this PR must then be retargeted. |
88fea6f to
7f9b305
Compare
7f9b305 to
93e79cc
Compare
93e79cc to
b78640a
Compare
Gate —
|
|
This follow-up on 88fea6f covers b78640a. The earlier review items are fixed in the code, but one regression and the missing live run remain. Fixed: the quiet window now re-prices from each loop capture, the confirmation latch is present, and the doc and test comments are corrected. The earlier review asked for a live latch run, and none was posted for this head. The remaining problem is in broadTransitionConfirmation. It decides arming once and stores After that fix, please post one live iPhone simulator run of On the open inline threads, the latch on confirmedQuietMs is fixed at this head, but it depends on the arming change above. These threads are fixed at this head and can be resolved: simulator bridge naming, 300ms scope in client-gesture, re-pricing per capture, flag help naming the bridge, removed benchmark block, fakeClock in the settle test, and quietMs doc on every return. Smoke Tests is still running and no failure has been reported. This change touches the settle loop that the |
b78640a to
e8bcff3
Compare
e8bcff3 to
84f170e
Compare
|
|
The 300 ms default window is not safe yet, because one of the two problems from the earlier review at b78640a is still open at 84f170e. The thread fixes and the latch are in. Checks are still pending, and no conflicts are known. The arming decision is still made once, on the first capture. In stable-capture.ts, Could the arming question be split by input lifetime? Rule out once from the run-constant inputs, then check completeness and backend on each capture inside This change touches the device-facing iOS settle path, and the latch with per-capture re-pricing has not been run on a device. The live runs in the PR body predate the latch commit and used the earlier trigger. Once the fix above is in, please post one iPhone simulator Eight earlier inline threads from the other review are fixed at this commit and can be resolved: grammar description, client-gesture scope, per-capture price, flag description, doc block, injected clock, confirmedQuietMs latch and quietMs doc. The latch thread is fixed in form, but it only helps once arming succeeds, which is the first finding above. I read the base and head code paths and did not run the tests or reproduce the first finding. I have not seen on a device that a non-tiny modal dismissal gives a rootless or empty first capture; the base code's comment is the only source for that. I did not check the mutation results or the 302 ms pre-fix measurement. All 14 non-passing checks were still queued when I looked, and none had failed. The diff changes Before merge, please re-check per-capture arming on every capture, add the regression for a non-tiny baseline with a rootless first capture, and post the live default-window modal-dismissal run on that head. |
84f170e to
d371573
Compare
Restacked on
|
| rep | settled | quietMs | waitedMs | armed | captures | late diff (t+2s) |
|---|---|---|---|---|---|---|
| 1 | true | 300 | 1862 | yes | 5 | {"additions":0,"removals":0,"unchanged":28} |
| 2 | true | 300 | 1838 | yes | 5 | {"additions":0,"removals":0,"unchanged":28} |
| 3 | true | 300 | 1816 | yes | 5 | {"additions":0,"removals":0,"unchanged":28} |
Armed on every rep: waitedMs ≥ 1500 while quietMs stayed 300, which is the floor held on top of the bridge's own window rather than instead of it. The late diff is the accuracy half — 28 unchanged / 0 additions / 0 removals two seconds after the settled response on all three, so the loop did not return early on a still-moving tree. The dismissal's own diff is identical across reps (27 additions / 3 removals / 1 unchanged).
Control on the same dismissal, asking for the general window explicitly:
{"settled":true,"waitedMs":2116,"captures":5,"quietMs":500}--settle-quiet still outranks the per-capture default, and the floor still applies underneath it.
Producer. The owner asked for the capture producer. settle does not carry one, so this is two facts rather than one field. First: quietMs: 300 is only reachable from producer === 'simulator-ax-bridge' — the Apple runner on the same xctest channel prices 500, and settle-default-quiet.test.ts pins that both ways — so the reported window is itself a producer-keyed fact. Second: the daemon records an ios.snapshot-source.acquire error only when the bridge refuses a capture and the read falls back, and that count over the whole session log was 8 before and 8 after the measured window, so no measured capture fell back. All 8 sit inside the earlier open request with foreground-owner-unverified. Caveats, both stated plainly: the bridge never names itself on the success path, so this shows "no refusal during the window" rather than a stamped producer, and press writes no per-request log at all, so I cannot show the loop's own five captures individually.
Session settle3522live closed, daemon stopped with --clean, and device status --udid BC54AC11-… reports no claims.
Your two non-blocking notes
- The
1500→1500msfix on the shared line — kept, it is inpost-action-observation-grammar.ts. - One exhaustive
settleQuietMstable beside the truncation table — declined here, and I think it belongs to a different commit.IOS_SNAPSHOT_TRUNCATION_EVIDENCEis exhaustive because iOS snapshot backend convergence and fast Simulator observation #2188 invariant 5 needs an answer for all four producers: a capture that reported nothing about truncation is only "not truncated" if its producer would have noticed. The window needs the opposite — the one producer with a measurement gets its window, and every other read, including Android, web, Linux and HarmonyOS, keeps 500 because that is main's number and no iOS table can enumerate their costs. An iOS-keyed table would either carry a non-exhaustive default branch, which is thedefaultQuietMsForCaptureyou pointed at wearing a table costume, or anIosSnapshotProducerunion that cannot type the non-iOS reads the accessor would still have to answer for.defaultQuietMsForCaptureis already one predicate over the producer; the table would move the same fact, not simplify it. Happy to be overruled. - The fixture numbers in the latch comment — fixed. The comment now states the invariant, and the
302msfigure left the test comment with it. The two remaining literals aresettleQuietMs: 150as a named caller (tester-army/e2e, both commits back onmain) and--settle-quiet 499vs500, which are the old bar's own operands, not measurements.
Threads and gate
All eight threads you listed were already resolved; the API now reports all eight isResolved: true, and no thread on the PR is open.
Full local gate rerun under the machine-wide lock at d371573d2, clean tree, started 2026-10-14T13:03:14Z:
pnpm install --frozen-lockfile exit=0
pnpm build exit=0
pnpm format exit=0 (tree clean afterwards)
git diff --exit-code exit=0
pnpm check:affected --run --base origin/main exit=0 → all runnable checks passed
check:affected selected 526 of 1,782 test files as related to the 8 changed files and ran them all green — 4,010 tests, 0 failures — with Fallow reporting No issues in 8 changed files (27.18 s), alongside format:check, lint, typecheck, check:layering and build.
Two notes on the lock, since the wait was long. The lock I found was an abandoned file: its recorded owner PID had been dead since an 11:26 gate, while an unrelated check:affected for another worktree was running without taking the lock at all. I did not steal it. I waited, and only reclaimed it once the owner file was unchanged from the dead PID I first observed, that PID was still dead, and the host had no gate-ish process left — the one-gate-per-host rule held throughout, and the reclaim is logged with those three conditions at each step. My gate released the lock at 13:06:01Z; another worker took it 8 seconds later, and that lock is not mine.
CI at this head is running now; I will report the outcome here, including the iOS Smoke lane, which the note on #3512 says to treat as diff-related on a settle diff until shown otherwise.
#3570 is restacked onto this head (git rebase --onto d371573d2), still a draft, GitHub reports it MERGEABLE. Its two commits came across unchanged apart from the two spots where the rebase had to land on the rebased parent: the daemon test's param bag (composedPlatformServicesFixture, which main renamed, plus this layer's clock injection) and the presented-sheet tree fixture, which main moved out of src/daemon/__tests__/support/ — that one now sits beside the root test that reads it rather than gaining a @agent-device/daemon/testing/... export entry for a single consumer. The draft also needed one real adaptation: three of the per-capture cases above pin an input this layer removes (viewport-root completeness on the capture), so on top they assert the surviving invariant — a rootless or empty mid-animation read still decides nothing and the run arms — plus the capture-channel case, which stays per-capture under the narrowed trigger. Its own four suites pass (63 passed, 1 expected fail) and tsc -b and lint are clean there.
|
The PR is ready from the code side. At d371573 the earlier findings from 84f170e are fixed, and no open inline thread still applies. Not blocking: Of the other reviewer threads, none still apply. These are all fixed at this head, so please resolve them: #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment). Smoke Tests and Coverage are still running on d371573, with no failure so far. I did not run the three new stable-capture tests myself. Their fail-before claim rests on my reading of the 84f170e code and on your stash run. The live run's settle diff (3 removals, 1 unchanged) points to a baseline of 4 nodes or fewer, so it shows the 300 ms window and the latch on device. It does not show the non-tiny rootless-first-capture route this change fixes, which only unit fixtures cover so far. No conflicts. Both checks need to finish green on d371573 before merge, and a failure inside an iOS settle or interaction step would point at this diff. |
iOS Smoke lane at d371573: unanswered deep-link confirmation, rerunRun 38564798395, job 115854626059, step "Run fixture-backed iOS simulator E2E smoke" failed at step 16, What happened on the device. Why this diff cannot reach it. The production changes are This is the launch-confirmation race that Rerunning the failed lane once: https://github.com/callstack/agent-device/actions/runs/38564798395 (attempt 2). I will report the result here. |
Review follow-up: window helper folded in, Smoke lane green on rerunSmoke at d371573 — not the diff, green on rerun. Run 38564798395 attempt 2 is The The move-only extraction PR is out of scope for this layer. Splitting On the rootless-first-capture route being fixture-only. Agreed, and I don't think a device run can close that gap: the route needs the first post-action capture to come back without the viewport root while the baseline is above the tiny-tree count, which on a real Simulator is a race with the dismissal animation rather than something a scenario can ask for. The three cases pin it where it is observable, and each asserts |
There was a problem hiding this comment.
All reported issues were addressed across 8 files
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
|
The default quiet window now comes from the capture it settles, and the delta after the earlier review at d371573 changes no behavior. This review covers fd4c0c7, and I found no code defects that block it. Not blocking, and you can take or leave these: the new tests 'a dismissal confirmation is not dropped by a later empty projection' (stable-capture.test.ts:329) and the three 'settle arms the confirmation on the first rooted XCTest capture after ...' cases (:374) say they guard defects that the merge-base ba7730a did not have, because that loop already carried On simplicity: the per-producer price is now one string compare, These open threads still apply: the error-path quietMs (#3522 (comment)) and the missing shortened-wait assertion (#3522 (comment)). Two lower-priority comment-wording threads also still apply. The Compatibility & Provenance check failed, and I could not read its failed-step log, so I cannot say if this PR caused it. The PR changes no exports, so the Fallow audit is the likely cause, but I did not confirm that. I did not run tests. I read the diff and the head code, and I did not check the rerun-green Smoke claim at d371573 against the run. Before merge, please get the Compatibility & Provenance log and fix or attribute the failure, and the queued Smoke and Coverage lanes must also pass. |
|
Update: head is now Compatibility & Provenance: cause and fixCause: Fallow audit reported Fix: The per-capture pricing is now its own step, Cubic threads (all 4 replied and resolved)
Owner review
#3570 has been restacked onto this head. Its own changes are unchanged ( |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
…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.
…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.
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.
…ry 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.
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.
c381817 to
40d8682
Compare
Rebased onto
|
|
The PR is ready at 40d8682. The earlier points are now fixed: the settle catch path and the mid-settle crash test report the real quiet window, the default-quiet test uses the non-broad fixture with the right thresholds, and both stale comments are corrected, so the four Cubic threads (#3522 (comment), #3522 (comment), #3522 (comment), #3522 (comment)) no longer apply and can be resolved. Not blocking: Checks are still pending: Smoke Tests is queued, not failed. This delta changes |
|
Part of #3470's follow-up work; does not close it. #3482 and #3512 have both merged, so this now targets
maindirectly and carries only its own 8 files.Summary
--settleasked every capture the same 500 ms quiet window. The iOS simulator snapshot bridge answers well inside that, 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 (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, not the one the action was performed against: an unrequested
--settleresolves 300 ms when that read came from the simulator snapshot bridge, and 500 ms for every other read — the Apple runner's own tree read, an Android capture from either producer (android-uiautomatorover ADB orappium-sourceover Appium), a webagent-browserread, an iOS tree from a provider acquisition, or one that declares no provenance at all — because 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 here 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
xctestand read at different costs.--settle-quietstill outranks both, andwait stablekeeps the general default of its own.Evidence for 300 ms
iPhone 17 simulator, one run per window per rep, order alternating so host load falls on both arms alike:
Accuracy held:
diff snapshot -itwo seconds after the settled response found no further change for the text entry at either window (0/6 both), and one further change in three of six runs for the press at either window — that screen keeps animating after the press whichever window is asked for, so the shorter window changes the wait, not the verdict's accuracy. (These A/B runs, and the live runs below, were taken on the pre-split build that still carried the presented-surface trigger since lifted to #3570; both arms request their window explicitly, so the comparison is this layer's window arithmetic alone.)Validation
Tested at
fd4c0c7cd(d371573d2plus the review follow-up that folds the quiet-window closure into the loop, no behaviour or test change).settle-default-quiet.test.tspins the window per capture producer in both mismatch directions (bridge baseline + runner re-read widens to 500 ms; runner baseline + bridge re-read narrows to 300 ms) and pins flag precedence; mutation check, measured when the key moved (6f43f54e6) and unchanged in shape here: keying the rule back on the baseline fails three of those cases plus one daemon case.generic-settle.test.tspins the three default-window routes on an injected fake clock (a 25 ms poll, a 30 mssleep, deterministic elapsed arithmetic — no real-time budget races).Arming is re-checked on every capture until one qualifies, and only the caller passing no pre-action projection rules the confirmation out for the run — the two per-capture inputs (viewport-root completeness, declared capture channel) decide nothing on their own. Three cases pin it, each with the 7-node settings table as the baseline so the tiny-baseline hatch cannot carry it, and each requiring
waitedMs >= 1500: after a rootless, an empty, or an other-channel first capture. All three fail at84f170e31(settling at 1004 / 802 / 502 ms).The per-capture re-pricing briefly broke the layer beneath it:
requiredQuietMsreceived the freshly resolved default each poll instead of the previous requirement, so the 1.5 s dismissal floor survived only while each consecutive capture kept qualifying. The comparison refuses a node-less side, so one empty mid-animation projection dropped the floor and the next empty read settled the run, well before the dismissal had met its floor. The floor is now latched once a capture proves the dismissal and maxed against the current window rather than replacing it (pinned bya dismissal confirmation is not dropped by a later empty projection; it fails against the pre-fix head, and passes against layer 1's source, which held the floor only by accumulating).The live run for this head — default window, three reps, armed on all three, with the late diff and the producer evidence — is in the review-response comment on this PR (
## The live run, headd371573d2, iPhone 17 Pro). The runs below predate it and are kept for the window-arithmetic comparison they carry.Live on the simulator after the earlier rework: default
press --settlereportsquietMs: 300at 658–664 ms over two captures;--settle-quiet 700still wins at 1050–1076 ms;wait stableis unaffected. Those runs were taken on the pre-split build, which still carried the presented-surface trigger since lifted to #3570; at this head the trigger ismain's, which arms on a superset of transitions, so the floor can only hold at least as well here — the window arithmetic they measure (quietMs, flag precedence, capture counts) is this layer's and unchanged.Gate
pnpm check:affected --run --base origin/mainat84f170e31: all runnable checks passed (exit 0) — 514 related test files / 3,998 tests, Fallow clean on the 8 changed files — held under the machine-wide lock, started2026-10-13T10:56:14Z.format,lint,tsc -b,check:layering,buildandvitest relatedall pass; the1500→1500msunit fix in the MCP/Node field description is covered by the schema/parser/registry suites (47 files, 309 tests) with no text pin against it.Note on scope
An earlier revision of this stack carried, one layer down, a presented-surface arm that keyed on the baseline's node count — the defect found in #3512's review (#3512 (comment)), where a dismissal whose baseline is a full tree stops confirming. That arm is not in this stack anymore: #3512 is now the window decoupling alone and keeps
main's trigger, and the narrowing (with that rule and the measured route table) sits on top of this layer as the draft #3570, blocked by #3565. So this layer arms exactly wheremainarms, only at every requested window, and nothing here depends on how #3565 resolves the fact.