You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 appendPrivateAXNodecall 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.
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:
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).
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.
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.tscollectIosStructuralIdentifierSuppression) 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 model — divergence-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 fixture — publication-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)
Introduce RawAXNode/PresentedNode and adapters with behavior-preserving presentation — golden outputs unchanged; enforcement lint lands here.
Route every backend through the module (still behavior-preserving per backend).
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 hittablewith the hittable-consumer inventory (anything equating hittable: false with "covered" migrates to the daemon's structured occlusion result). Release-note recording impacts.
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.
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+.
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.
Problem
The iOS runner has three snapshot backends (recursive tree, query sweep, private AX) that converge on one
SnapshotNodewire 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
appendPrivateAXNodecall 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):
-imembershipshouldInclude)RunnerTests+Snapshot.swift:810vsRunnerTests+FlatSnapshotFiltering.swift:43hittableelement.isHittable— three meanings for one fieldSnapshot.swift:841vsPrivateAXPresentation.swift:56vsSnapshot.swift:1520--scopeSnapshot.swift:873vsFlatSnapshotFiltering.swift:10--rawrawDiagnosticPlannever readsoptions.raw, so a recoveredsnapshot --rawreturns regular presentation labeled rawSnapshotCapturePlan.swift:476(only read)valueSnapshot.swift:1121vsRunnerAXSnapshotBridge.m:803depthSnapshot.swift:239vsPrivateAXPresentation.swift:57D1 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 independentannotateCoveredSnapshotNodes— 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:
RawAXNodetrees: 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 toRawAXNode, diagnostics, and differential artifacts (never a per-node wire field).presentRegular(clip fold, eligibility, hidden-content hints, scope semantics) andpresentRaw(normalization, depth/scope if explicitly requested, no visibility pruning — preserving ADR 0004's raw contract andinteractive ⊆ raw).Two membership vocabularies (external pass 4, finding 1)
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.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 structuralOtherwrappers (non-hittable, no label/value — the React Native testID shape,noise.tscollectIosStructuralIdentifierSuppression) 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):
PresentationOptionsis the source of truth; each backend receives a derivedCaptureHintcomputed by one purecaptureHint(PresentationOptions). Generalized conservatism contract: aCaptureHintmay 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.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.rect; raw presentation emits the raw frame.rawFrameinforms 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 thehittableinventory): every post-wirerectconsumer is reviewed and tested against effective-rect semantics before the carrier lands. Diff baselines re-baseline once; thenodes[0].rect-as-viewport contract holds.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 platformfindScopeNodeis 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;presentRegularstays 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).PresentedNodewrapper only the presentation module constructs; payload assembly accepts onlyPresentedNode;querySelector/system-modal keep explicit constructors as labeled single-element reads; a CI lint allowlistsSnapshotNode(construction sites. SwiftPM split deferred until the interface survives the first complete backend migration.What "impossible" means, precisely
presentRawexempt), downgradingsnapshotQualitywith 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 aKnownDivergencewaiver registry (strict expiry/reproduction), a realtreepin + 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: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.buildSnapshotState({ nodes, backend: 'xctest' }, { snapshotInteractiveOnly: true })fromsrc/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 hittableButtonwith no label/identifier/value — published, proving the interactive branch rather than content; the identifier-only structuralOtherwrapper suppressed by declared policy; a hittable identifier-onlyOther(outside the structural shape) surviving. Non-vacuity recorded per the regression-test rule: with thecollectIosStructuralIdentifierSuppressioncall disabled innoise.ts, exactly the suppression test fails (promo-banneris 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)
RawAXNode/PresentedNodeand adapters with behavior-preserving presentation — golden outputs unchanged; enforcement lint lands here.interactive OR semantic content; single delta = dropping hittable-non-other), scope-in-presentation + removal of the annotation-layer scope passes,presentRawsplit, 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 runnerhittablewith thehittable-consumer inventory (anything equatinghittable: falsewith "covered" migrates to the daemon's structured occlusion result). Release-note recording impacts.treepin + 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.jsontwin pattern); the post-wire annotation layer carries no scope on either platform. C6's verdict extension (android-helper) and the invariant inbuildSnapshotStatecover all mobile backends. Cross-runtime equivalence overall is pinned by a shared goldenRawAXNode → Presentedconformance 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:
captureHintsingle-sourcing;PresentedNodeenforcement; 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 runnerhittablefor 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-
Othersuppression, and the end-to-end publication fixture; (2) therawFrame-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 hittableButton, 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.