Skip to content

Commit 8c61aea

Browse files
authored
docs(adr): repair 0004 paths, record the RUNNER_BUSY asymmetry, settle 0026 status (#2909)
* docs(adr): fix retired paths, record RUNNER_BUSY asymmetry, settle status ADR 0004 cited `src/snapshot/**`, retired by the capture-kit move (#2385). The ownership and boundary sentences now point at the `snapshot-policy` declaration in scripts/layering/architecture-ownership.ts, and the three policy bullets name the paths that exist. ADR 0005 documented runner occupancy and drain but never recorded the shipped asymmetry: a read-only request waits out `RUNNER_BUSY` and is resent, while a mutating one is attempted once with nothing replayed. Both decisions are keyed on the daemon-side `RUNNER_COMMAND_TRAITS` read-only trait, not on a runner-side classification. The close-and-pooling sentence keeps its scope so it no longer reads as contradicting that policy. ADR 0026 was still `Proposed` after #2758 and #2759 shipped its ownership decision. It is `Accepted`, with the two open #2754 steps named as open work rather than as the decision. Closes #2892 * docs(adr): mark the unshipped ADR 0026 rules and narrow two claims Recording ADR 0026 as Accepted made two unshipped rules read as current: the "every removed source" row and the ejection ledger in Contracts. Both now name #2754 step 5 as the open contract, and the `ScrollArea` passages record the decision #2759 shipped instead of asking for one. ADR 0004's boundary sentence said the gate covers the facet; it covers only the roots `snapshot-policy` declares, so the overlay modules beside that tree are now named as outside it. ADR 0005's keying sentence claimed the runner reports nothing; it is the runner-side classification of the command that plays no part, while the refusal code selects the budget.
1 parent d5c21f8 commit 8c61aea

4 files changed

Lines changed: 70 additions & 42 deletions

File tree

‎docs/adr/0004-ios-snapshot-backend-strategy.md‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,16 @@ those runtimes; it does not imply that Swift and TypeScript share an implementat
102102
The macOS XCTest runner is the desktop-surface exception: its already-presented nodes bypass the iOS
103103
presentation engine and continue through neutral snapshot assembly.
104104

105-
The same split now holds for the three remaining Wave 4 policies tracked by #1983, so
106-
`src/snapshot/` is the host-side owner of snapshot policy generally rather than of presentation
107-
alone:
105+
The same split now holds for the three remaining Wave 4 policies tracked by #1983, so the host-side
106+
facet in `@agent-device/capture-kit` owns snapshot policy generally rather than presentation alone:
108107

109108
- **Freshness recovery.** The freshness window, the Android staleness classification and its
110-
thresholds, and the retry loop live in `src/snapshot/snapshot-freshness/`. The loop is
111-
parameterized by a classifier and a retry schedule, so "how long may a backend lag behind a real
112-
transition" is a policy input rather than a constant the loop owns. The schedule is stated as a
113-
duration budget; the loop derives the deadline from the window's `markedAt` itself, so the
114-
budget is always spent from the action and a caller has no absolute instant it could get wrong.
109+
thresholds, and the retry loop live in `packages/capture-kit/src/snapshot/snapshot-freshness/`.
110+
The loop is parameterized by a classifier and a retry schedule, so "how long may a backend lag
111+
behind a real transition" is a policy input rather than a constant the loop owns. The schedule is
112+
stated as a duration budget; the loop derives the deadline from the window's `markedAt` itself, so
113+
the budget is always spent from the action and a caller has no absolute instant it could get
114+
wrong.
115115
`src/daemon/session-snapshot-freshness.ts` keeps only what needs a session: reading and retiring
116116
the window on store-owned `SessionState`, and choosing the comparison baseline from snapshot
117117
lineage. It remains the declared R7 owner of `androidSnapshotFreshness`.
@@ -125,8 +125,8 @@ alone:
125125
reclassifying. No message shape is consulted anywhere on that path, so rewording helper or
126126
wrapper prose cannot move the reason, and prose that merely reads like a timeout does not become
127127
one — both directions are asserted end to end against the real producer.
128-
`src/snapshot/snapshot-timeout-policy.ts` reads the reason; the human-facing hint is derived
129-
from it rather than decided alongside it.
128+
`packages/capture-kit/src/snapshot/snapshot-timeout-policy.ts` reads the reason; the
129+
human-facing hint is derived from it rather than decided alongside it.
130130

131131
The published `details.androidSnapshotTimeoutScreenshot` payload is vocabulary in
132132
`@agent-device/contracts/snapshot-timeout-evidence`, a union whose arms encode which claims can
@@ -136,13 +136,15 @@ alone:
136136
The daemon keeps the ordering that genuinely needs it: resolving a bound screenshot runtime,
137137
writing the artifact, annotating it from the stored observation, and emitting the diagnostics.
138138
- **Screenshot-overlay policy.** Which Android nodes earn an overlay ref, and what rectangle an
139-
overlay for one of them covers, live in `src/snapshot/screenshot-overlay/`. The daemon keeps
140-
approved artifact and ref assembly only: ranking, projection to screenshot pixels, drawing, and
141-
PNG IO.
142-
143-
`scripts/layering/snapshot-presentation-boundary.test.ts` enforces the direction for the whole
144-
facet: nothing under `src/snapshot/` may import `src/daemon/`. It carries a positive control,
145-
because a filter that stopped matching would look identical to a boundary being obeyed.
139+
overlay for one of them covers, live in `packages/capture-kit/src/screenshot-overlay*.ts`. The
140+
daemon keeps approved artifact and ref assembly only: ranking, projection to screenshot pixels,
141+
drawing, and PNG IO.
142+
143+
`scripts/layering/snapshot-presentation-boundary.test.ts` enforces the direction, but only across
144+
the roots `snapshot-policy` declares in `scripts/layering/architecture-ownership.ts`: nothing in
145+
that snapshot tree may import `src/daemon/`, while the overlay modules beside the tree sit outside
146+
those roots and are outside that gate. It carries a positive control, because a filter that stopped
147+
matching would look identical to a boundary being obeyed.
146148

147149
The residual call sites #1983 also named are audited and deliberately left in place.
148150
`src/daemon/direct-ios-selector.ts` carries no presentation policy: `isLocalIosRunnerSession` and

‎docs/adr/0005-ios-runner-interaction-lifecycle.md‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,27 @@ therefore drained), and left intact by a transport failure or an unstamped recov
6565
healthy `ok` is never read as proof of drain because a private-AX snapshot can be served while an
6666
abandoned tree crawl still grinds on the XCTest main thread (#2552).
6767

68+
What a `RUNNER_BUSY` refusal then costs the caller turns on the daemon's own per-command read-only
69+
trait, not on any runner-side classification of the command: `RUNNER_COMMAND_TRAITS` in
70+
`packages/platform-apple/src/runner/runner-command-traits.ts`, read through
71+
`isReadOnlyRunnerCommand` where `runAppleRunnerCommand` decides whether to wrap the send in a resend
72+
loop. The asymmetry is shipped, and asserted in `runner-command-busy-resend.test.ts`: a read-only
73+
command treats the refusal as *not yet*, waiting the drain out on the busy-specific budget sized to
74+
outlast it and resending until the runner answers or the window ends; a mutating command treats the
75+
same refusal as *not mine to send again* — one attempt, and the refusal reaches the caller unwrapped
76+
with nothing replayed. Neither path pays a status probe, because a structured reply already answered
77+
"did my command run?" (the bypass in `runner-lifecycle.ts`). The same trait already decides the
78+
startup-preflight skip above, so it is the single place a change to that classification lands.
79+
6880
Close that would retain a runner for reuse first stops it when that occupancy is set, awaiting the
69-
stop so the lease is released before the next request, because a runner still finishing
70-
watchdog-abandoned work refuses every command until it drains or escalates to `RUNNER_WEDGED`; pooling
71-
it back to the next `open` hands the same stalled runner to the caller and `close` recovers nothing.
72-
Killing the process is the only way to abort uncancellable XCTest work. This is the `RUNNER_WEDGED`
73-
restart from #1105 applied at the close boundary rather than after the wedge threshold elapses.
81+
stop so the lease is released before the next request. A runner still finishing watchdog-abandoned
82+
work does refuse every command sent to it until it drains or escalates to `RUNNER_WEDGED`, and that
83+
fact is stated here for the close-and-pooling decision only: the read-only resend above is one live
84+
caller waiting the same refusal out on its own budget, and `close` does not inherit that wait on the
85+
pool's behalf. Pooling the stalled runner back hands it to the next `open` and `close` recovers
86+
nothing. Killing the process is the only way to abort uncancellable XCTest work. This is the
87+
`RUNNER_WEDGED` restart from #1105 applied at the close boundary rather than after the wedge
88+
threshold elapses.
7489

7590
When XCTest reports a root accessibility snapshot failure such as `kAXErrorIllegalArgument`, the
7691
runner treats the cached app target as suspect. Interactive snapshots fail closed to a truncated

‎docs/adr/0026-scroll-clip-authority.md‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
## Status
44

5-
Proposed (2026-09-22). Refines ADR 0004's fact/interpretation boundary for scroll geometry; does not
6-
supersede it. Implementation contract: #2754.
5+
Accepted (2026-09-22). The ownership decision is shipped: #2758 reads an indicator's owner off the
6+
parent edge, climbing only same-frame ancestors — the tolerance the #2754 step 3 survey justified on
7+
real captures — and #2759 pairs the two eligibility lists and settles `ScrollArea` for macOS. Two
8+
#2754 steps stay open work rather than part of this decision: the runner-stage differential arm, and
9+
the typed ejection disposition the "every removed source" row below names. Refines ADR 0004's
10+
fact/interpretation boundary for scroll geometry; does not supersede it.
711

812
An interactive iOS snapshot of a list dropped every row after a row holding selectable text. A
913
`UITextView` is a UIScrollView and XCTest publishes its scroll indicator inside the text, so the
@@ -22,7 +26,7 @@ walking up past the indicator's parent, and that guess decided which nodes exist
2226
| Removing a node **because a scroll band hides it** | Requires evidence: the clip fold's result, or a band whose owner is the indicator's parent. A guess about ownership may not do it |
2327
| Removing a node by semantic delegation — a collapsed row, a duplicate label, a wrapper's content | Out of scope here. That is compaction, and it keeps its own authority |
2428
| Weaker evidence than a parent edge | Directional hints (`hiddenContentAbove` / `hiddenContentBelow`) and nothing that changes membership |
25-
| Every removed source | Ends with a typed disposition. Internal evidence, never wire vocabulary |
29+
| Every removed source | Must end with a typed disposition — the open #2754 step 5 contract, not shipped code. Internal evidence, never wire vocabulary |
2630

2731
## Contracts
2832

@@ -47,25 +51,30 @@ neither eligible set contains it, and the iOS runner never emits it — it origi
4751
`AXScrollArea` mapping. That helper's trees do reach these rules, through `snapshot-desktop-surface.ts`
4852
→ `ios-snapshot-runtime.ts` → `publishIosSnapshot`, so on that surface a `ScrollArea` host can be dropped
4953
by eligibility while its children re-parent past it, which leaves a parent-edge lookup with no owner.
50-
Reusing this rule for macOS therefore needs a `ScrollArea` eligibility decision made there, not carried
51-
over from the iOS claim above.
54+
Reusing this rule for macOS therefore needed a `ScrollArea` eligibility decision made there, not
55+
carried over from the iOS claim above, and #2759 made it with cases rather than by admitting the
56+
type for iOS's sake: `scrollarea` stays out of both eligible sets, a `ScrollArea` carrying content
57+
survives eligibility on those trees and owns its band, and a label-less one is dropped, leaving its
58+
indicator with no owner to band — which under-clips (safe) instead of mis-clipping the enclosing
59+
list (`eligibility-parity.test.ts`).
5260

5361
**Why the band exists at all.** XCTest reports a scroll view's frame spanning the bars and the safe
5462
area, not the visible track. In the pinned Settings tree the `CollectionView` frame is the whole screen,
5563
0–874, while its indicator track is 116–812 (`runner-presentation.test.ts`). The frame alone therefore
5664
cannot express visibility, and deleting indicator clipping would return content that is scrolled under
5765
the chrome. The band is necessary; only *who owns it* was ever in question.
5866

59-
**Visibility ejection needs evidence, and every ejection is recorded.** Most of the engine's 23 removal
60-
sites delegate semantics or drop decoration — a collapsed row, a duplicate label, a wrapper's content, a
61-
system indicator — and this ADR does not touch their authority. It governs the four visibility-driven
62-
sites among them, the scroll band's one and the keyboard band's three, alongside the clip fold's own
63-
inclusion decision in `geometry-policy.ts`. Of those, the scroll band is the one a weak signal can reach,
64-
and directional hints are its safe outlet when nothing is the indicator's parent. Every source index ends
65-
either presented, with its representatives, or removed with a typed reason, which makes the ledger complete
66-
by construction rather than gated. The existing `presentedIndexesBySourceIndex` in
67-
`ios-snapshot-engine/semantic-index.ts` is the shape to extend; a second parallel ledger would be a second
68-
source of truth.
67+
**Visibility ejection needs evidence, and every ejection must be recorded.** Most of the engine's 23
68+
removal sites delegate semantics or drop decoration — a collapsed row, a duplicate label, a
69+
wrapper's content, a system indicator — and this ADR does not touch their authority. It governs the
70+
four visibility-driven sites among them, the scroll band's one and the keyboard band's three,
71+
alongside the clip fold's own inclusion decision in `geometry-policy.ts`. Of those, the scroll band
72+
is the one a weak signal can reach, and directional hints are its safe outlet when nothing is the
73+
indicator's parent. Every source index must end either presented, with its representatives, or
74+
removed with a typed reason, which makes the ledger complete by construction rather than gated;
75+
#2754 step 5 still owes that reason, and `presentedIndexesBySourceIndex` in
76+
`ios-snapshot-engine/semantic-index.ts` records representatives and no removal reason today. That
77+
ledger is the shape to extend; a second parallel ledger would be a second source of truth.
6978

7079
## Refuted alternatives
7180

@@ -118,10 +127,12 @@ source of truth.
118127
upward. So `findScrollIndicatorContainer` returns null when the node is itself a scroll type;
119128
`runner-presentation.test.ts` pins a scroll host labelled as an indicator leaving its parent list's
120129
band intact.
121-
- **macOS is already a second consumer of these rules.** Desktop capture runs the engine through
122-
`snapshot-desktop-surface.ts` → `ios-snapshot-runtime.ts` → `publishIosSnapshot`, and its trees carry
123-
`ScrollArea`, which neither eligible set admits. The parent-edge rule needs its own decision there
124-
before it is reused, not a carry-over from the iOS claim.
130+
- **macOS is already a second consumer of these rules, and #2759 settled its `ScrollArea`
131+
question.** Desktop capture runs the engine through `snapshot-desktop-surface.ts` →
132+
`ios-snapshot-runtime.ts` → `publishIosSnapshot`, and its trees carry `ScrollArea`, which neither
133+
eligible set admits: a content-bearing one survives eligibility and owns its band; a label-less
134+
one is dropped and leaves its indicator with no owner, under-clipping instead of mis-clipping the
135+
list. `eligibility-parity.test.ts` pins both outcomes and the TS/Swift eligibility pair.
125136
- **Ejection inventory precedes any API change.** 23 suppression sites across ten rules, and two of them
126137
(`scroll`, `transitions`) already rewrite rects on other nodes in the same pass that ejects — the
127138
combination the proposed API split claimed to make impossible.

‎docs/adr/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
| [0023 End-State Entry-to-Platform Hop Trace](0023-end-state-hop-trace.md) | the hop counting definition, the ordered `press`/Android and dual-arm `snapshot`/iOS chains, per-hop roles, and the deletion test behind the numbers ADR 0019 and ADR 0022 quote |
2828
| [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix |
2929
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, and why no pose-control command exists |
30-
| [0026 Scroll Clip Authority — Ownership Is the Parent Edge (Proposed)](0026-scroll-clip-authority.md) | iOS scroll indicators and the visible band, why ownership is read from the parent edge instead of an ancestor walk, what may remove a node from an interactive snapshot, and ejection dispositions |
30+
| [0026 Scroll Clip Authority — Ownership Is the Parent Edge](0026-scroll-clip-authority.md) | iOS scroll indicators and the visible band, why ownership is read from the parent edge instead of an ancestor walk, what may remove a node from an interactive snapshot, and ejection dispositions |
3131
| [0027 Descriptor Root Size vs Eager-Closure Budget (Proposed)](0027-descriptor-root-vs-eager-closure-budget.md) | splitting `packages/command-registry/src/registry.ts`, the ADR-0019 eager-closure module-count budget, and why a byte-neutral hub split is currently unshippable |
3232
| [0028 Capability-Family Cell Vocabulary — One Runtime Source (Proposed)](0028-capability-family-cell-vocabulary.md) | adding a capability operation family, `UnavailablePlatformRuntimeFacts` / `UNAVAILABLE_CELLS`, `INTERACTOR_OPERATIONS`, and why an eight-package fan-out recurs |
3333

0 commit comments

Comments
 (0)