Skip to content

iOS snapshot backends: make interpretation divergence impossible by construction (acquire/present split) #1797

Description

@thymikee

Problem

The iOS runner has three snapshot backends (recursive tree, query sweep, private AX) that converge on one SnapshotNode wire model but implement interpretation — visibility, clipping, membership, hittability, scope, raw rendering — separately per backend. Nothing relates the copies: no shared implementation for several policies, no cross-backend test, no CI gate. Every incompatibility so far has been fixed by hand-synchronizing a second copy, and the class survives each point fix.

Exhibit A is #1784 (8b698e8): the scroll-overflow leak (text overflowing a scroll container but inside the viewport was visible on private AX, hidden on the tree) was fixed by making appendPrivateAXNode call the tree's clip helpers (isVisibleInRegularSnapshot, scrollContainerAnchor, rememberHiddenContentHint). Correct fix — but it's a second, manually-kept-in-sync call site. Nothing stops the next drift.

Live divergences at HEAD (3020195):

# Field/flag Tree Private AX Where
D1 -i membership interactive-type OR hittable OR content (shouldInclude) every visible node survives (decorative images, captions, contentless others) RunnerTests+Snapshot.swift:810 vs RunnerTests+FlatSnapshotFiltering.swift:43
D2 hittable center-in-viewport + occlusion scan purely type-based; sweep uses real element.isHittable — three meanings for one field Snapshot.swift:841 vs PrivateAXPresentation.swift:56 vs Snapshot.swift:1520
D3 --scope re-roots acquisition via live query substring match over label/identifier/value Snapshot.swift:873 vs FlatSnapshotFiltering.swift:10
D4 --raw selects raw tree rendering silently ignored — the private-AX tier of rawDiagnosticPlan never reads options.raw, so a recovered snapshot --raw returns regular presentation labeled raw SnapshotCapturePlan.swift:476 (only read)
D5 value stringified at presentation stringified at acquisition (ObjC boundary) — irreducible, see below Snapshot.swift:1121 vs RunnerAXSnapshotBridge.m:803
D6 depth collapsed visible depth emitted raw traversal depth emitted; also a capture parameter (ladder/frontier) Snapshot.swift:239 vs PrivateAXPresentation.swift:57

D1 is the mechanical cause of the documented 139-vs-48 node gap on the same screen (comment in src/daemon/__tests__/post-gesture-stabilization.test.ts:449). The daemon's whole cross-backend quarantine (#1569 — re-baselining, no-effect vetoes, backend pinning) exists to work around these outputs being incomparable. Occlusion is additionally implemented twice across the seam — the runner's scan and the daemon's independent annotateCoveredSnapshotNodes — a seventh duplication resolved below.

Proposal: backends acquire, they never interpret

Three named stages with a typed boundary, all runner-side, plus a declared daemon compaction layer above them:

  1. Acquisition (per backend) emits RawAXNode trees: reported facts only (type raw value, label, identifier, value, frame, states, children) plus acquired facts where a source has them — native hit-test results, actions (private AX custom actions). An acquired fact is data, not interpretation — it can never gate eligibility or derived fields, and it stays internal to RawAXNode, diagnostics, and differential artifacts (never a per-node wire field).
  2. Augmentation — acquisition-side enrichment that needs live handles, keyed on raw shape only: collapsed-tab expansion, private-AX deep-frontier extension, custom-action reads. Carve-out: backends may use heuristics to allocate acquisition budget; they may never decide eligibility.
  3. Presentation — ONE deep module, the only interpreter, exposing two projections: presentRegular (clip fold, eligibility, hidden-content hints, scope semantics) and presentRaw (normalization, depth/scope if explicitly requested, no visibility pruning — preserving ADR 0004's raw contract and interactive ⊆ raw).

Two membership vocabularies (external pass 4, finding 1)

  • Eligibility — owned by runner presentation. Eligibility = interactive-type OR semantic content, where semantic content is a non-empty label, identifier, or value regardless of element type (the shipped tree's own rule, Snapshot.swift:883); decorative means unlabeled, never a type. This is where cross-backend divergence dies: eligibility is backend-neutral and single-implementation. The only intended eligibility delta vs the shipped tree policy is dropping the "hittable-non-other" branch.
  • Publication membership — owned by daemon compaction (buildSnapshotState: noise rules, row collapse, keyboard suppression, refs). Compaction operates on eligible nodes, is a single backend-blind implementation (it runs after the wire and never sees which backend produced the tree, so it cannot reintroduce backend divergence), and its suppressions are declared policies, each pinned by a test. Declared today: identifier-only structural Other wrappers (non-hittable, no label/value — the React Native testID shape, noise.ts collectIosStructuralIdentifierSuppression) are eligible but suppressed at publication; identifier-only nodes outside that shape survive.

Key design points (hardened by three internal adversarial reviews and four external review passes — history below):

  • Options are single-sourced. PresentationOptions is the source of truth; each backend receives a derived CaptureHint computed by one pure captureHint(PresentationOptions). Generalized conservatism contract: a CaptureHint may reduce acquisition only when the adapter guarantees completeness for the requested projection; otherwise it controls budget/order only. Named obligations: scoped containment, interactive-query completeness, visible-depth frontier completeness. Inherently lossy cases are declared fidelity limitations in the residue registry.
  • The clip fold (regular projection only): effective rect = frame ∩ inheritedClip; scroll containers narrow the clip for descendants. Frameless nodes (zero-frame or one-axis-degenerate, per CGRect emptiness) stay eligible when they carry content but are never hittable and never clip; hint bookkeeping threads the nearest anchor's index+rect.
  • Geometry: effective rect on the wire; raw geometry is runner-internal only (external pass 4, finding 2). Regular presentation emits the effective visible rect as rect; raw presentation emits the raw frame. rawFrame informs runner-internal work only (traversal-identity dedup before serialization). Everything after the wire — row equality and wrapper suppression (rows.ts), the occlusion annotator (snapshot-occlusion.ts), actionable-descendant matching (interaction-targeting.ts), RN overlay classification/ranking, tap centering (interaction-touch-point.ts) — intentionally consumes effective geometry; that is the declared contract, not an accident. Migration step 3 carries a rect-consumer inventory (analogous to the hittable inventory): every post-wire rect consumer is reviewed and tested against effective-rect semantics before the carrier lands. Diff baselines re-baseline once; the nodes[0].rect-as-viewport contract holds.
  • Scope: one specification, implemented inside each presentation runtime (external pass 4, finding 3). The scope matcher is part of the presentation spec: the Swift implementation lives inside iOS presentRegular/presentRaw (also validating conservative scoped acquisition), the TypeScript implementation lives inside Android's presentation, and shared golden fixtures prove their equivalence. The post-wire annotation layer has no scope pass at all: the daemon's second iOS scope pass is removed, and Android's platform findScopeNode is removed (see Android native-a11y → snapshot: conform to the acquire/present engine contracts (sibling of #1797) #1832). One no-match semantics, defined in the spec.
  • hittable = geometric actionability without an occlusion scan, feeding only the emitted field, never eligibility; presentRegular stays linear under a hard complexity gate; occlusion becomes the daemon annotator's sole property. Fact availability can never change which nodes exist or what fields say (proof obligation P2b).
  • Enforcement: an internal PresentedNode wrapper only the presentation module constructs; payload assembly accepts only PresentedNode; querySelector/system-modal keep explicit constructors as labeled single-element reads; a CI lint allowlists SnapshotNode( construction sites. SwiftPM split deferred until the interface survives the first complete backend migration.
  • Presentation runs runner-side, per attempted tier, inside the capture-plan loop. Deadline contract: cooperative checks; expiry discards the whole tier's presented output with a named quality reason; the plan advances only if budget remains. Acquisition and presentation timings tracked separately; presentation never feeds the XCTest-channel penalty breaker.
  • Layer ownership, final vocabulary: runner presentation owns eligibility, visibility, hittability, hints, scope semantics; daemon compaction owns publication membership (noise/rows/keyboard — declared, pinned policies), occlusion annotation, refs, and published-node dedup — all on effective geometry. macOS is a platform-policy input to the shared algorithm, never a backend exception.

What "impossible" means, precisely

  • Interpretation divergence across backends (D1, D2's rule, D3's semantics, D4's rendering, D6's emission): removed by construction — one eligibility interpreter, unconstructable second copy, fact-availability neutrality. Daemon compaction cannot reintroduce backend divergence because it is a single backend-blind implementation; what it can change (agent-visible publication) is governed by declared, test-pinned policies.
  • Raw-acquisition fidelity: not unifiable — fenced by (a) the choke-point invariant (every framed node emitted by regular presentation intersects its cumulative effective clip; frameless nodes exempt; presentRaw exempt), downgrading snapshotQuality with a named reason; (b) property tests over the presentation module's public interface; (c) deterministic fixture differentials per PR, live tree-vs-private-AX differentials nightly, with a KnownDivergence waiver registry (strict expiry/reproduction), a real tree pin + penalty-clear hook, and a multi-dimensional parity report (actionable-node matching, membership disagreement, field disagreement, unmatched nodes), each dimension ratcheted separately beside the differential manifest.

Evidence

Two layers of evidence on branch claude/ios-accessibility-backend-compat-7m1pdn:

  • Design modeldivergence-proof.ts (verdict MODEL OBLIGATIONS PASS, printed coverage table; models eligibility, not publication). P1: same raw tree → 32 (tree policy) vs 42 (private-AX policy) nodes, 10 divergent; 1000/1000 random trees diverge. P2: eligibility self-divergence 0/1000; labeled-image/identifier-only/value-only all eligible while the unlabeled decorative image is dropped from regular and kept in raw (34 regular / 43 raw); frameless and degenerate-frame handling pinned; 0 invariant violations; in-clip hit points; no evidence serialization. P2b: fact-availability neutrality 0/1000. P3: fixture-pair and randomized-mutation attribution, 0 unattributed.
  • End-to-end publication fixturepublication-membership.test.ts exercises the production interface: buildSnapshotState({ nodes, backend: 'xctest' }, { snapshotInteractiveOnly: true }) from src/daemon/handlers/snapshot-capture.ts, so the real ordering of normalization, group pruning, iOS compaction, occlusion annotation, and refs fires. Cases: label content published (labeled image); value content published; a bare interactive node — a hittable Button with no label/identifier/value — published, proving the interactive branch rather than content; the identifier-only structural Other wrapper suppressed by declared policy; a hittable identifier-only Other (outside the structural shape) surviving. Non-vacuity recorded per the regression-test rule: with the collectIosStructuralIdentifierSuppression call disabled in noise.ts, exactly the suppression test fails (promo-banner is published) while the other cases still pass; restored, all 28 tests in the presentation suite are green.

Live confirmation on a device (fixture app, both backends forced, diffed through the shared presentation) is the differential above and the acceptance test for this issue.

Migration plan (types before semantics)

  1. Introduce RawAXNode/PresentedNode and adapters with behavior-preserving presentation — golden outputs unchanged; enforcement lint lands here.
  2. Route every backend through the module (still behavior-preserving per backend).
  3. Change semantics — each landing as a separately reviewed intentional delta with its own re-baseline, never one bulk regeneration: eligibility (interactive OR semantic content; single delta = dropping hittable-non-other), scope-in-presentation + removal of the annotation-layer scope passes, presentRaw split, clip fold, geometry carrier with the rect-consumer inventory (rows equality, occlusion annotator, actionable-descendant matching, RN overlay classification, tap centering, diff baselines, freshness signatures — each reviewed and tested against effective-rect semantics), occlusion removal from runner hittable with the hittable-consumer inventory (anything equating hittable: false with "covered" migrates to the daemon's structured occlusion result). Release-note recording impacts.
  4. Add enforcement: choke-point invariant, tree pin + penalty-clear hook, per-PR fixture differentials + nightly live differentials, multi-dimensional parity ratchets beside the manifest.

Gotchas: the iOS CI workflow runs an explicit -only-testing: allowlist — new in-bundle tests must be added there; macOS compiles the same Swift files (platform policy is an explicit parameter); ADR 0004 gets amended with the acquire/present contract, the eligibility/publication vocabulary, and the declared-residue list.

Platform siblings & shared contracts (holistic view)

The six contracts are platform-agnostic Snapshot Engine Contracts: C1 fact-availability neutrality · C2 hint conservatism · C3 two projections (interactive ⊆ raw) · C4 geometry carrier (effective rect emitted; post-wire consumers on effective geometry by declaration) · C5 presentation failure contract · C6 output invariant + quality disclosure.

This issue is iOS's conformance work; #1832 is Android's (C1/C2/C4 violated, C3/C5 partial, C6 absent; divergence axis = API level and layer, not backend; combined before/after diagrams live there). Shared-code fixes land once for both: the C4 geometry carrier, occlusion ownership (daemon annotator sole implementation), and the scope specification — one spec, Swift implementation inside iOS presentation, TypeScript implementation inside Android presentation, equivalence pinned by shared golden fixtures (scaling the tap-point-policy.json twin pattern); the post-wire annotation layer carries no scope on either platform. C6's verdict extension (android-helper) and the invariant in buildSnapshotState cover all mobile backends. Cross-runtime equivalence overall is pinned by a shared golden RawAXNode → Presented conformance suite run against both presentation implementations. Testing lanes are shared under #1781; the corpus/scene-model strategy gets its own issue before lane-2 work. Differential axes: iOS tree-vs-private-AX; Android API-23-vs-24+.

Review history

Internal (3 passes) → v3: captureHint single-sourcing; PresentedNode enforcement; presentation runner-side, outside the penalty timer; frameless escape hatches; D5 reclassified as irreducible; CI/macOS/replay gotchas.

External pass 1 (6 findings) → v4: backend-neutral fields + P2b; conservative scope contract; two projections with the invariant regular-only; geometry split; deadline as failure contract; invariant quantifier corrected. Verdict: "genuinely resolved rather than merely reclassified."

External pass 2 (5 gaps) → v5: effective rect as the emitted rect; evidence never on the wire; conservatism generalized to every hint; occlusion removed from runner hittable for linearity; migration reordered types-before-semantics. Plus macOS platform policy, differential cadence, ratchets beside the manifest, SwiftPM deferral.

External pass 3 (4 corrections): Android C1 interim patch labeled disclosure-only (#1832); P3 attribution actually implemented; dead geometric membership branch removed; harness verdict renamed with a coverage table + CGRect degenerate frames. Occlusion move and re-baselines endorsed with their obligations.

Verification pass (1 blocker): the harness content rule was narrower than shipped — corrected to non-empty label/identifier/value regardless of type; decorative fixtures unlabeled; labeled-image/identifier-only/value-only membership pinned.

External pass 4 (3 cross-layer gaps): (1) membership was not end-to-end — resolved with the eligibility vs publication vocabulary, the declared identifier-only structural-Other suppression, and the end-to-end publication fixture; (2) the rawFrame-preserves-dedup claim could not survive serialization — resolved by declaring post-wire consumers intentionally use effective geometry, scoping raw geometry to runner-internal dedup, and adding the rect-consumer inventory to step 3; (3) scope ownership contradicted the two-runtime architecture — resolved as one scope specification implemented inside each presentation runtime with golden-fixture equivalence, and no scope in the post-wire annotation layer.

Final verdict (external): eligibility vs publication membership — approved; effective geometry for all post-wire consumers with an inventory — approved; one scope specification with Swift and TypeScript implementations — approved; overall design approved for implementation. The one remaining evidence correction (the publication fixture bypassed buildSnapshotState; the interactive case was label-carrying) is applied in commit 941018e: the fixture now calls the production interface, the interactive case is a bare hittable Button, and non-vacuity is recorded. No further architecture review required.

Status

Approved for implementation. Proceed per the migration plan; each step-3 semantic delta lands as its own reviewed re-baseline. The corpus/scene-model testing strategy gets its own issue before lane-2 work begins.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions