* 0.21.1 * feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) iOS apps that sign in via ASWebAuthenticationSession present the identity provider in com.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AX primaryApp. Serve and drive the sheet in place. A closed registry names the host (shared by the TypeScript and Swift sides under a parity test); the runner reads and drives it without activation and never adopts it as the session target; and the Simulator route detects a running host with a cheap device-scoped ps probe and takes the runner path, since the bridge would serve the occluded app tree as if healthy. open refuses to launch a registered host, and captures carry a system-surface disclosure. Presence is foreground state, not tree content: a torn-down host serves a richer tree than a live one, so content heuristics cannot tell them apart. The never-activate guard is what keeps the foreground predicate sound, which also makes the stale-tree failure mode unrepresentable for this flow. Closes #2438 * chore(gates): register contracts/ios-system-surface in the export snapshot * fix(ios): close the system-surface correctness gaps from review Presence probe: absence and probe failure are no longer reported as "no surface". The probe returns present/absent/unknown and the route takes the runner for anything but a proven absent, so a sheet opened between two captures, or a probe that cannot answer, can no longer fall through to a bridge capture that would answer confidently from the occluded app tree. Only a positive observation is memoized. The probe now matches with pgrep and reads only a matched pid's environment, which is ~3x cheaper than the previous full process-environment dump and stops copying every process's environment. Open guard: the refusal moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check can no longer launch the host. Terminating a host is refused too, since that cancels the presented session just as launching it does. Comparison: the surface identity now reaches SnapshotState, and tap-failure corroboration refuses outright when a baseline and a post-action capture disagree about it, instead of letting app and sheet captures meet in legacy same-presentation matching. Selector routes disclose an iOS system surface through the shared disclosure seam rather than reading only the Android field. The contracts import in the launch path is deferred so the app-lifecycle facade's eager closure stays flat, and the runner's comment prose is trimmed because apple/runner ships to npm as uncompiled source. * fix(ios): route the system-surface probe through the Apple tool provider The probe shelled out with runCmd, so every eligible capture spawned a real process even in provider-backed tests that stub the Apple tool seam — 17 real spawns in one scenario file, which is both wasted work and added latency on timing-sensitive settle paths. It now goes through runAppleToolCommand like the sibling ps probe, so a stubbed provider answers instead of spawning. * fix(ios): disclose a skipped bridge when the surface probe cannot answer Routing an unprovable probe to the runner is right, but the early return also skipped runFallback, so the response lost its warning and kept an identity that could still be compared against a bridge publication. An unknown probe now falls back through the same disclosed path as a bridge failure, with its own reason. * fix(ios): keep surface identity through comparison, find, and probe scope A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an unreadable or truncated environment could route a live sheet to the occluded app tree. Only a scope naming a different device is a real negative now; a missing one stays unknown. The shared post-gesture comparison token used comparisonKey or the backend alone, so an app capture and a sheet capture — both XCTest — compared equal and a sheet appearing or dismissing read as a stable surface. The token now carries the surface, which covers stabilization, verify and settle through the one path they share. Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the shared disclosure helper could not report the sheet on either outcome. It is preserved now. Each fix has a regression that fails without it. * fix(ios): keep surface identity in verify and settle comparisons `--verify` compared node digests and `--settle` diffed node-only baselines, so an app baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one presentation: a meaningless changed verdict, and a whole-surface replacement presented as an in-surface diff with refs. The pre-action baseline now travels with the surface its capture described, from the resolution and the session frame through to the settled capture, and one module owns the comparison for both routes. Across a surface change no same-surface claim is made: evidence reports the transition instead of a digest comparison, the settled diff and its refs are withheld, and both payloads disclose the transition. * refactor(test): move the cross-surface settle tests onto their source mirror The #2438 cross-surface cases were appended to `settle.test.ts`, taking it over the test-file size ratchet (2528 lines, 2359 at the merge-base). They assert the comparison `post-action-surface.ts` owns, so they move to that module's mirror test file, and the device double plus the trees both files drive move to a sibling fixtures module under `__tests__/` rather than being duplicated. Pure move: every test and every assertion is unchanged, and `settle.test.ts` is back under its merge-base length. * test(daemon): cover the cross-surface settle refusal on the generic route `scroll --settle` and `back --settle` plumb the baseline's surface identity through `baselineSurfaceBundleId`, but nothing asserted it: the generic route had zero coverage of the #2438 refusal, so a regression there would have been silent while the element-targeted route stayed green. Assert the same contract the targeted route guarantees, in both directions and for both commands: no diff is attached across an app/sheet boundary — therefore no tail and no `refsGeneration` — the transition is disclosed, and the settle observation still reports its own verdict alongside that disclosure. Each direction falsifies a different half of the plumbing, so both are needed: dropping the baseline's surface identity fails only the sheet-to-app tests (an app baseline has no surface id to lose), and dropping the settled capture's fails only the app-to-sheet tests. No production change: the plumbing was correct, only untested. * refactor(ios): inline the single-caller surface disclosure wrapper iosSystemSurfaceDisclosure() only mapped provenance-or-nothing onto the shared constant for one caller, so the caller now reads the constant directly and the wrapper is gone. Its test becomes a test of the transition disclosure, which is the function that still earns its place (the "sheet is gone" sentence). readAppleSnapshotResult also called readSystemSurfaceProvenance twice inside one spread; it is bound to a local and read once. * docs(adr): state that a presented surface outranks a requested bundle id prepareActiveCommandContext checks for a presented system surface before it resolves or activates command.appBundleId, so a command naming a different app is still served the sheet. That is intended, but the code does not read that way; the amendment now says it plainly. * refactor(ios): carry the system surface in the capture's comparison lineage A capture of an in-place system surface (a web sign-in sheet) describes a different presentation than a capture of the app, so it must never compare equal to one. The `present` branch of the iOS snapshot route returned a bare fallback, so that capture carried no comparison identity at all, and two comparison sites hand-rolled the distinction from `iosSystemSurfaceBundleId` instead. The probe now reports which host it matched, and the `present` branch goes through `runFallback` like the `unknown` branch beside it, lineaged to `<device>:<host bundle>`. The comparison key then differs from an app capture's by construction, so the surface branch in `hasMatchingPresentation` and the surface concatenation in `snapshotComparisonKey` are gone: both sites are plain key equality again, and neither knows that system surfaces exist. Two captures of the same surface still share a lineage, so they stay comparable with each other. A presented surface is not a bridge failure, so it gets its own warning wording: the bridge is inapplicable here, not unavailable. * refactor(interaction): carry the pre-action baseline as one surface-scoped value The same pre-action tree travelled as a flattened nodes/surface pair at every boundary, and each boundary rebuilt it with a conditional spread. Carry SurfaceScopedNodes itself instead: - ResolvedInteractionTarget gets preAction?: SurfaceScopedNodes, replacing the preActionNodes/preActionSurfaceBundleId pair and the PreActionBaselineFields intersection on all three arms of the union. - SettleObservationCommandOptions gets baseline: SurfaceScopedNodes, replacing baselineNodes/baselineSurfaceBundleId. - RefResolution carries tree: SurfaceScopedNodes instead of nodes plus a loose surfaceBundleId. That retires preActionBaselineFields(), preActionBaseline(), evidenceBaseline(), the local SettleBaseline type, the split-then-reassemble in settleObservationCommand, and the 'preActionNodes' in resolved narrowing tests. SurfaceScopedNodes moves to contracts, where ResolvedInteractionTarget can name it; only two sites now mint one from a SnapshotState. Behaviour is unchanged: the cross-surface guarantees keep their existing tests. * fix(ios): identify a surface capture by what the runner served The `present` path stamped the capture's comparison lineage from the host-side presence probe. That probe answers about a host PROCESS and deliberately stays positive while a dismissed host lingers, so during that window the runner truthfully returned APP content while the route lineaged it to the HOST: the sheet capture before the dismissal and the app capture after it compared equal, and a post-gesture poll could read the transition as a stable surface. Derive the identity from the returned capture's `systemSurface` instead - the runner stamps the surface it actually served - and say which of the two the capture holds in the warning. The probe's host is now evidence only: it names the matched host in a route diagnostic so a lingering window is legible in the daemon log. Other reasons keep their lineage and wording byte for byte. Captures that bypass the route's planning (a pinned backend, a custom-actions read) also reach the runner, and the runner serves the sheet there too. They carried no comparison key at all, so a sheet and app content fell through to legacy presentation matching as one presentation and could corroborate a tap across the two. The capture owner now gives those a surface-scoped identity as well, with no fallback-source residue: nothing fell back. An app capture off the route is untouched. * fix(ios): derive a served surface identity at the one stamping point A runner fallback's comparison identity was decided per call site. The `present` path and the off-route path read the runner's `systemSurface` stamp, but the plain `runFallback` path did not: it stamped the app lineage the route had planned, whatever the runner returned. The probe and the capture are separate observations, so a sheet can appear in the gap between them. With the bridge circuit already disabled for the generation, an app capture and a later sheet capture both received the same app-generation key, so tap corroboration could treat two different surfaces as comparable. `stampFallback` now owns the decision for every runner fallback: the surface the runner served outranks the app lineage the route planned. The reason the bridge was skipped survives either way, and app-generation evidence leaves with the app lineage it describes, so two captures of the same sheet still compare equal. `runSurfaceFallback` keeps only the reason, which is the one thing that path decides.
26 KiB
ADR 0004: iOS Snapshot Backend Strategy
Status
Accepted. Amended after local iOS Simulator acquisition moved to the host AX bridge while the public surface remained two modes: regular interactive snapshots and raw diagnostic snapshots.
The Apple platform runtime owns acquisition routing and its generation-scoped XCTest fallback.
Host-side iOS validation, semantic presentation, and publication are owned by
@agent-device/capture-kit; structured snapshot quality verdicts and fallback warnings make
degraded or recovered output observable end to end.
Context
Agent Device exposes iOS UI state through host AX acquisition on local Simulators and the long-lived XCTest runner everywhere else. The snapshot surface has two durable needs:
- agent-facing regular context, where the important contract is the effective user-visible UI, fixed controls such as tab bars, and scroll-hidden hints for content outside visible scroll containers;
- rich diagnostics and selector disambiguation, where a raw recursive XCTest snapshot is useful because it preserves hierarchy, static text, wrappers, scroll containers, and ancestry.
These needs should not share one capture strategy blindly. Recursive XCUIElement.snapshot() is
rich, but some real app trees can make XCTest fail with kAXErrorIllegalArgument or main-thread
timeouts while the same app remains visually usable. Bluesky is the current known example:
lower-level accessibility services can describe simulator screens even when XCTest recursive
snapshots and typed XCUIElementQuery enumeration degrade to no useful child nodes. Physical iOS
devices can show the same XCTest accessibility-channel timeout shape even when no lower-level
semantic backend is available.
This is different from presentation filtering. The daemon's snapshot presentation can hide noisy or inaccessible nodes, but it cannot recover nodes that XCTest never returns. More filters, Maestro-specific heuristics, or retries in the daemon would only make this failure slower and less predictable.
Decision
Keep XCTest as the iOS automation runner. Route eligible local iOS Simulator snapshots through the host AX bridge, present them once through the shared TypeScript engine, and use one typed XCTest fallback when bridge acquisition or presentation fails. Disable the bridge for that app generation after fallback; a new app generation re-enables it. Physical devices, providers, custom-action captures, and interactions remain on their existing owners.
A WebKit page — Safari's, or a WKWebView's — lives in a WebContent process and reaches UIKit's
tree as an AXRemoteElement under the web view, with its children in that other process. The
bridge reads one process, so it delivers the element as a leaf. The source refuses a tree in which
such a leaf sits under a WebView-typed ancestor and reaches the viewport (remote-content-boundary)
instead of publishing a screen without its page: refs issued from it would target the host views
around the page rather than the page. A leaf whose frame is zero-area or off screen hosts nothing
the capture can miss and is published; one that reports no frame is refused, because nothing proves
it empty. Remote elements outside a web view are not classified — no capture has shown one — and a
web view truncated away by the node or depth cap stays disclosed as truncation. XCTest resolves
remote elements, so the fallback serves the page (#2484).
The refusal opens the generation circuit like any other bridge failure, so a hybrid app that showed
one web screen takes XCTest for its remaining native screens until it relaunches — the 0.20.x path
for every screen. Re-asking the bridge per capture would instead charge a refused bridge round trip
to every wait poll on the web screen; the circuit keeps that cost to one capture per generation.
Keep the two public snapshot strategies explicit:
- Regular visible strategy: use recursive XCTest snapshots, emit the effective user-visible
tree plus visible ancestors and scroll-hidden hints, and fall back through the capture plan when
XCTest returns sparse output. A node inside a scroll container is user-visible only when it
intersects both the app viewport and the nearest visible scroll container. Offscreen descendants
should be visited to set
hiddenContentAbove/hiddenContentBelow, not emitted as normal visible nodes. This strategy must not use an arbitrary node-count cutoff: fixed controls that are later in traversal order, such as bottom tab bars after long lists, are part of the visible UI contract. - Raw diagnostic strategy: use recursive XCTest snapshots for raw snapshots, diagnostics, and cases that need hierarchy. Raw output is allowed to be noisy and large; if the transport cannot carry the response, fail explicitly instead of silently truncating the tree at a hard node count. If XCTest reports a real AX serialization failure, preserve that error instead of pretending the UI is empty.
- Host AX strategy: acquire local Simulator trees as raw facts through the bounded host bridge. Every result crosses the same presentation boundary before publication. XCTest fallback carries explicit source residue, and comparisons require matching producer, intent, app generation, presentation key, and residue. Physical devices should use an equivalent non-XCTest semantic backend only if Apple exposes a supported channel.
The daemon should make degraded output observable. If an iOS interactive snapshot contains only the application root or another sparse shape, surface a structured quality verdict and warning so agents know the snapshot is degraded output rather than proof that the screen has no controls.
Host-side ownership boundary
The shared TypeScript side has one snapshot-presentation facet. The neutral acquisition-to-presented
carrier and clip-fold geometry contract live in @agent-device/contracts/snapshot-presentation, while
@agent-device/capture-kit owns host-side iOS planning, folding, projection, eligibility, semantic
compaction, validation, and publication. Platform-specific presentation adapters retain only the
policy mechanics that cannot yet cross their runtime boundary. Daemon assembly owns only the ordering
of capture, compaction, occlusion, and ref publication. It does not own the presentation vocabulary
or a second geometry carrier.
Android acquisition remains in its platform module and adapts its raw hierarchy to the shared
carrier. Swift keeps its runner-side SnapshotPresentation implementation because it consumes the
capture-plan tier before the process boundary. The iOS engine fixture is the shared proof between
those runtimes; it does not imply that Swift and TypeScript share an implementation.
The macOS XCTest runner is the desktop-surface exception: its already-presented nodes bypass the iOS
presentation engine and continue through neutral snapshot assembly.
The same split now holds for the three remaining Wave 4 policies tracked by #1983, so
src/snapshot/ is the host-side owner of snapshot policy generally rather than of presentation
alone:
-
Freshness recovery. The freshness window, the Android staleness classification and its thresholds, and the retry loop live in
src/snapshot/snapshot-freshness/. The loop is parameterized by a classifier and a retry schedule, so "how long may a backend lag behind a real transition" is a policy input rather than a constant the loop owns. The schedule is stated as a duration budget; the loop derives the deadline from the window'smarkedAtitself, so the budget is always spent from the action and a caller has no absolute instant it could get wrong.src/daemon/session-snapshot-freshness.tskeeps only what needs a session: reading and retiring the window on store-ownedSessionState, and choosing the comparison baseline from snapshot lineage. It remains the declared R7 owner ofandroidSnapshotFreshness. -
Timeout evidence. Whether a capture failed because the hierarchy never arrived is decided once, at the deepest boundary that has the evidence, from machine-defined values only:
snapshot-capture-failure-reason.tsmaps the helper's structurederrorTypefield (java.util.concurrent.TimeoutException, by exact equality) and the SIGKILL exit code 137 to the typed reasonaccessibility-timeout(ANDROID_CAPTURE_FAILURE_REASONSin@agent-device/contracts/android-snapshot-quality). The helper-result, session-protocol, and killed-instrumentation error constructors attach it; every layer above rewraps it rather than reclassifying. No message shape is consulted anywhere on that path, so rewording helper or wrapper prose cannot move the reason, and prose that merely reads like a timeout does not become one — both directions are asserted end to end against the real producer.src/snapshot/snapshot-timeout-policy.tsreads the reason; the human-facing hint is derived from it rather than decided alongside it.The published
details.androidSnapshotTimeoutScreenshotpayload is vocabulary in@agent-device/contracts/snapshot-timeout-evidence, a union whose arms encode which claims can coexist. The annotated arm carries a non-empty ref tuple, so "annotated with zero refs" is not a state a caller can build, and no arm stores a ref count: a count beside the refs is a second source of truth the type system cannot hold in step, so it is derived from the refs instead. The daemon keeps the ordering that genuinely needs it: resolving a bound screenshot runtime, writing the artifact, annotating it from the stored observation, and emitting the diagnostics. -
Screenshot-overlay policy. Which Android nodes earn an overlay ref, and what rectangle an overlay for one of them covers, live in
src/snapshot/screenshot-overlay/. The daemon keeps approved artifact and ref assembly only: ranking, projection to screenshot pixels, drawing, and PNG IO.
scripts/layering/snapshot-presentation-boundary.test.ts enforces the direction for the whole
facet: nothing under src/snapshot/ may import src/daemon/. It carries a positive control,
because a filter that stopped matching would look identical to a boundary being obeyed.
The residual call sites #1983 also named are audited and deliberately left in place.
src/daemon/direct-ios-selector.ts carries no presentation policy: isLocalIosRunnerSession and
readSimpleIosSelectorTarget are session routing (device family, provider ownership, the
stabilization window), while deriveDirectIosNodeSelector and isDirectIosSelectorFallbackError
are selector derivation and ADR 0011 delegation-on-error. The latter two are pure and
daemon-independent, but their owner would be the selector pipeline governed by R19, not this
facet; moving them under ADR 0004 would widen it to a boundary it does not decide. The
observation and interaction consumers — selector-capture-runtime.ts,
deferred-interaction-outcome.ts, snapshot-capture.ts and
interaction-touch-android-freshness.ts — now reach freshness only through the facet or its
session binding.
New consumers must use the facet rather than add another daemon presentation path.
The acquisition/presentation boundary has two explicit vocabularies. An acquired input is raw node evidence accompanied by its capture hint, viewport, lineage, and residue; the host engine folds and projects that evidence. A presented input is the runner's primary payload plus validation facts; the host engine validates it and performs semantic compaction once. Regular eligibility decides which nodes belong in the regular presentation, while publication adds refs and emits only the primary payload. An optional unscoped quality payload is validated for classification evidence and is never published.
Regression Notes
PR #639 made XCTest AX serialization failures explicit instead of swallowing them as empty snapshots. That was the correct diagnostic change, but it exposed apps whose accessibility trees XCTest cannot serialize.
Later work moved recovery into the regular visible capture plan so healthy apps keep the fast recursive tree path while degraded app classes can still return bounded, honest output when fallback tiers are the only available source of visible controls.
Issue #1105 showed a second failure shape on the same app class: instead of failing fast with
kAXErrorIllegalArgument, the recursive tree capture can grind for many seconds on
heavy/animating screens before failing, pushing the chained plan past the runner's main-thread
watchdog and burying the main queue under retries. The plan now carries its umbrella deadline
into the query-sweep and private-AX tiers (later ladder rungs stop when the budget is spent),
and a slow, timed-out, or watchdog-abandoned XCTest-backed capture penalizes the XCTest
accessibility channel for that bundle for a bounded window. Subsequent regular plans derive the
next step from backend traits (effectiveSnapshotCapturePlan): when a runnable non-XCTest backend
exists, they defer to that independent tier; when it does not, as on physical iOS devices today,
they run a short XCTest probe instead of the full tree slice so healthy screens can recover without
repeating the hostile-screen grind. The raw diagnostic plan is exempt — it keeps tree-first error
propagation.
Recovery conformance and depth hints
The host AX bridge and the XCTest runner's private AX bridge recover rejected deep requests with
different native representations, ladders, and completeness evidence, and they stay separate
implementations. contracts/fixtures/ios-ax-recovery-conformance.json is their shared, executable
recovery contract: each producer replays every case through its own adapter, and the fixture
records per-producer expectations plus the intentional differences, so a change to either recovery
path is measured against the same synthetic native world. Common executable policy is extracted only
where the fixture proves equivalence; a shared engine is not a goal.
The host source additionally keeps a bounded accepted-depth hint per resolved target generation and producer. It changes only the native levels the first request asks for, is learned only from a finished recovery that observed a rejection, expires by hinted-capture count so ordinary screens probe back to the full depth, and is never shared across apps, generations, or producers. The route's generation circuit remains the only lifecycle owner.
Consequences
Regular snapshots remain the right tool for agents and Maestro compatibility because they describe what a user can currently perceive and interact with. Raw snapshots remain the right tool when hierarchy matters. Both may still fail loudly on XCTest-broken trees; that failure is useful because retrying the same recursive capture is unlikely to reveal a different tree.
A future AX-service backend is the correct place to regain Bluesky-class semantic coverage. It should be added as a platform backend with its own lifecycle, protocol, normalization, timing metrics, and fallback rules, not as another special case inside the XCTest runner.
The acquire/present migration begins with a behavior-preserving typed seam: acquisition backends
construct RawAXNode, SnapshotPresentation alone constructs PresentedNode, and response payloads
accept only presented nodes. Its second behavior-preserving step makes every capture-plan backend
return SnapshotAcquisition and routes the exhaustive backend switch through one
SnapshotPresentation.present call. PresentationOptions is the stable request-policy input to
that boundary. Until the remaining migration steps move interpretation into the boundary, acquisition
still reads those options and raw nodes intentionally carry the derived fields produced by the
existing backends.
The first semantic migration layer makes regular eligibility backend-neutral inside
SnapshotPresentation: the top-level viewport carrier survives, and every other node needs an
interactive accessibility type or a non-empty label, identifier, or value. Hittability no longer
admits an otherwise ineligible node. Raw membership remains unchanged. This is runner eligibility,
not daemon publication membership; backend-blind daemon compaction retains ownership of its declared
noise suppressions. When eligibility removes a structural wrapper, presentation reparents its
surviving descendants to the nearest surviving ancestor and normalizes their indexes and depths.
The second semantic layer makes scope a presentation specification rather than an acquisition or
daemon-compaction policy. A trimmed non-empty scope selects the first presentation-preorder match
whose subtree contributes to the requested projection; matching inspects label, identifier, and
value case-insensitively. The selected subtree is re-rooted, depth is applied relative to that root,
and no match publishes an empty healthy projection. Swift and TypeScript implementations are pinned
by contracts/fixtures/snapshot-scope-policy.json.
Scoped iOS acquisition stays broad (including when depth is requested) until an adapter can prove a
narrowing hint complete. The daemon never reapplies scope after the wire; Android selects its root
inside its TypeScript presentation and desktop surface runtimes retain their platform projection.
The third semantic layer splits presentation into two projections and gives acquisition one input.
SnapshotPresentation.captureHint derives a CaptureHint from the request; backends read the hint,
never PresentationOptions. A hint names the projection the acquisition must serve and may narrow
acquisition only where the backend can prove the narrowing complete for that projection: scope and
its relative depth never narrow, raw depth does (raw depth is traversal depth), and the raw
projection never carries interactiveOnly. presentRegular folds visibility, eligibility, scope,
and scroll hints; presentRaw is the acquired tree, normalized, with scope and depth applied only
when the request asked for them — so interactive ⊆ regular ⊆ raw holds per backend rather than per
backend implementation. snapshot --raw -i therefore returns the acquired tree instead of an
interactive-filtered one.
Two structural rules keep a backend from answering a request with the other projection, the shape
that let a recovered snapshot --raw return viewport-pruned nodes labeled raw: the raw diagnostic
plan is derived from SnapshotBackendKind.supportsRawProjection rather than hand-listed, so a
backend with no hierarchy to return (the query sweep) cannot be planned for raw; and presentation
compares the requested projection with the hint the acquisition was captured under, dropping that
tier with a structured IOS_SNAPSHOT_PROJECTION_MISMATCH failure instead of presenting it under the
requested label.
The fourth semantic layer moves the clip fold itself into presentation. Acquisition backends are
fact serializers: every traversed node is emitted at raw traversal depth with its reported frame,
and SnapshotAcquisition carries the viewport. The fold returns a typed carrier with both values:
raw.rect remains runner-internal reported geometry, while regular presentation writes the
carrier's effective rectangle through the existing wire rect field; raw projections and direct
single-element reads retain reported geometry. presentRegular runs the one visibility
interpreter for every backend — viewport ∩ scroll-container clip, the ancestor projection cursor
(an out-of-clip Cell or scroll container hides descendants whose clamped frames would otherwise
leak back into the viewport), the sub-pixel decoration rule, hidden-content hints booked onto
scroll anchors, and reparenting of survivors with collapsed depth. The fold also narrows the
emitted hittable to the clip: nothing outside its clip, and nothing without geometry, is ever
hittable regardless of what a backend reported. Platform differences are a SnapshotFoldPolicy
input to the shared algorithm (iOS cursor-projected; macOS/tvOS plain viewport intersection),
never a backend exception. The presentation owner validates every framed regular node against its
cumulative effective clip before constructing SnapshotPresentation.PresentedNode; frameless and
degenerate semantic carriers stay
eligible but are never actionable, while raw projection remains exempt by contract. A violation is
a typed IOS_SNAPSHOT_PRESENTATION_FAILED capture failure with the named presentation-failed
snapshot-quality reason, preserved through recovery and the existing TypeScript verdict/warning
contract.
The visible-depth frontier completes that migration for unscoped regular captures. CaptureHint
keeps raw traversal depth (--raw --depth) separate from regular presented depth. A
hierarchy-capable tree capture walks through structural wrappers until each branch ends or reaches
the requested presented depth; regular presentation then applies the depth limit after the shared
fold and eligibility collapse. This keeps shallow probes bounded by the requested presented
frontier without inventing a raw-depth multiplier. Scoped captures remain broad because depth is
relative to the scope root selected in presentation.
Backend capability declarations are part of the contract, and they describe how much acquisition
work a regular depth request bounds — never whether the backend may answer it. Every backend
serves a regular --depth request because presentation applies the presented-depth cut to
whatever hierarchy was acquired: the recursive tree stops acquisition at the presented frontier,
the flat query sweep has only its root and one presented level (so a cut past depth 1 returns the
sweep unchanged), and private AX walks its raw-depth ladder and is cut afterwards
(presentation-cut). Completeness below an acquisition cap is disclosed the same way it is for an
unscoped capture — through truncated and effectiveDepth — because a depth-capped regular
capture is a subset of the unscoped one from the same backend. Refusing the request instead
produced no answer at all: a plan pinned or deferred to private AX fell through to the synthetic
sparse root, which the daemon then rejected as a missing viewport (#2403). Raw depth remains
acquisition depth for every backend.
Acquisition-side limits remain explicit: raw private-AX captures still disclose their bridge-side node cap, the flat query sweep still drops frameless elements because it has no hierarchy to attach geometryless semantics to, and the recursive tree still has no raw-depth extension for deep XCTest trees. Presentation cannot repair any of those acquisition limits.
When adding new iOS snapshot behavior, maintainers should first decide which strategy owns it. If a change tries to make regular snapshots fast by dropping visible controls behind a node budget, or tries to make raw snapshots safe by silently truncating, it is probably crossing strategy boundaries.
Amendment: in-place system surfaces (issue #2438)
Some UI is presented out of the app's process by a system bundle — com.apple.SafariViewService,
which hosts ASWebAuthenticationSession and SFSafariViewController for delegated OAuth/OIDC
sign-in. Two facts, both verified live on the iOS 26.2 Simulator, shape how it is captured:
- The surface dies if activated.
XCUIApplication.activate()orsimctl launchon the host cancels the authentication session and blacks the view. So the host must be observed and driven in place, never activated, andopenrefuses to launch a registered host. - The local host AX bridge cannot see it. While the sheet is up the app remains the AX
primaryApp, so the bridge serves the (occluded) app tree as if healthy. Only the XCTest runner, addressing the host by bundle id, can read and drive the sheet.
Decision. A closed registry names these hosts (contracts/fixtures/ios-system-surface-hosts.json,
mirrored by the TypeScript and Swift registries under a parity test). When a registered host is
genuinely presented, the runner serves and drives it in place and never adopts it as the cached
session target; the session binding stays on the app, so once the surface is gone the next command
resolves back to the app. On the Simulator a cheap, device-scoped host-side probe (a registered
host process running for the device) routes the capture to the runner instead of the bridge; when no
host is running the bridge fast path is untouched.
A presented surface also outranks an explicitly requested bundle id: the runner checks for a
presented host before it resolves or activates command.appBundleId, so a command that names a
different app is still served the sheet. That is deliberate — the sheet occludes the screen, so
the named app has nothing readable under it, and the capture discloses which surface it describes —
and it costs nothing once the sheet is gone, because the session binding never moved.
Presence is XCUIApplication.state == .runningForeground, not tree content. The live spike showed a
torn-down host still serving a richer tree than a live one, so content heuristics cannot separate
live from dead; foreground state can. Crucially, the only way a host is foreground with a stale tree
is if it was activated or relaunched — which the open guard and the in-place policy both refuse — so
this fix and the never-activate guard are one design: the guard is what makes the foreground
predicate sound. This also makes issue #2438's second bug (a stale tree served confidently after
teardown) unrepresentable for the delegated-auth flow, because the session never binds to the host.
Captures of a system surface carry a response-level systemSurface provenance and the shared
IOS_SYSTEM_SURFACE_DISCLOSURE, so the agent is told the controls belong to a system sheet rather
than the app. They are also lineaged to the host rather than the app, so their comparison identity
differs from an app capture's by construction: every consumer that asks "are these two captures the
same presentation" refuses a cross-surface pair through ordinary key equality, and no comparison
site carries a surface check of its own. Physical devices always use the runner, so the in-place
serve applies there without a route change; the Simulator route probe is the only
Simulator-specific piece.