Skip to content

ADR-0017 · Cross-platform parity automation

Status: 🟡 Accepted, phased — layers 0–2 shipped 2026-07-27, layer 3 shipped for web + Android (iOS job written, first run pending); layer 4 specified · Depends on:ADR-0011 (one RN codebase → three surfaces), ADR-0015 (systemic vs composition surface) · Related: QRS-208, parity verification

The one-line thesis

Parity is not verified by adding assertions to the gates we have — those gates cannot reach the platforms where it breaks. So automation is layered by what each layer can physically observe: static fingerprints in milliseconds, web rendering in minutes, native rendering only on a device. Each layer states its own blind spot, and a green layer is never reported as parity.

Context

Between 2026-07-26 and 07-27, four defects shipped in the same shape — a change that was correct on the surface it was tested on and broken on one it was not:

DefectChangeVerified onBroke on
QRS-190className unregistered on PressableScalenative (fine there)web — padded controls collapsed to text size
QRS-203the QRS-190 fix (cssInterop registration)web (measured)both natives — every PressableScale rendered with no style at all
QRS-206the QRS-201 theme fix (media query removed)web (6-combo e2e matrix)both natives — light tokens under dark ones
QRS-207Android material ripple, per product speciOS + web (no-op there)Android — square grey block over rounded controls

Every one passed npm test and npm run e2e. That is the finding that shapes this ADR: the problem was never thin assertions, so more assertions in the same places would have caught none of them.

Why the existing gates could not see any of it

GateStructural blind spot
npm run e2e (Playwright)Runs expo export -p web. Nothing about Android or iOS rendering is observed.
npm test (jest-expo)Mocks Reanimated's createAnimatedComponent to identity, so AnimatedPressable === Pressable. Native-only wrapper behaviour — prop forwarding, interop registration, android_ripple — does not exist to be asserted.
check:designEnforces that a drift row exists, not that the design is right.
tsc / ESLintTypes and syntax are identical across platforms; that is precisely why these bugs type-check.

Two of the four were fixes whose own verification was the thing that failed. QRS-206 is the sharpest case: the line it deleted was simultaneously a bug on web and native's only source of dark tokens, so the correct change on one surface was destructive on the other — and only a native build could have said so.

The second-order finding: native verification was effectively unreachable

Two environment traps, both discovered while fixing QRS-206, made "just check it on a device" fail in ways that look like something else:

  1. The local APK is arm64-only (single ABI keeps the release build inside the commit limit and the APK inside the size budget). The standard emulator is x86_64, so that APK installs and then dies with SoLoaderDSONotFoundError: couldn't find DSO to load: libreactnative.so. That reads as a broken build.
  2. Metro's cache key excludes @imported files. NativeWind compiles Tailwind from global.css, which imports theme.css; editing a token leaves global.css byte-identical, so a stale stylesheet is silently reused. This produced a completely convincing false negative mid-investigation — the web export had run with --clear while the Android build reused a pre-fix stylesheet, reproducing "fixed on the PWA, broken on native" from cache alone.

If verification is this easy to get wrong, people fall back to the gate that is easy and green. Both traps are now first-class flags on the guarded build script (--abi=, --reset-metro) and asserted by rule R7, because their absence is invisible until the next incident.

Decision

Five layers, ordered by what they can observe, fastest first. Each is honest about its ceiling.

Layer 0 — Claude Code hooks (shipped)

The agent-facing layer, and the only one that acts at the moment of the change rather than at a checkpoint. It exists because CLAUDE.md already said the right things and they were not followed: the instruction "npm run build:android is the only sanctioned local build path" has been in the repo since QRS-012, and an agent ran gradlew.bat assembleRelease anyway, inheriting a stale shell and OOM-ing the build (QRS-204).

HookEventBehaviour
tools/hooks/guard-bash.mjsPreToolUse (Bash)Blocks the three bypasses that have actually caused incidents: direct gradlew assemble*, expo run:android --variant release (all-ABI OOM), and prettier --write on theme.css (lowercases @cssInterop → silently kills native dark mode).
tools/hooks/on-systemic-edit.mjsPostToolUse (Edit/Write)On any edit to packages/tokens/**, apps/*/src/ui/** or the styling pipeline: runs the static gate immediately, and states what will and will not count as verification for that surface.
tools/hooks/session-parity-summary.mjsStopReports, from git rather than recollection, which systemic files are uncommitted and whether commits are pushed — the QRS-206 round-two failure was telling the user to git pull work that had never left the machine.

Design rules, learned the hard way in the first ten minutes: quote-aware matching (the guard's first act was to block the command that was testing it, because the pattern sat inside a quoted JSON payload — a guard that fires on discussion of the thing it guards gets switched off), and fail open on a malformed payload (a broken hook must never wedge a session; CI is behind it). Both pinned by npm run test:hooks.

Layer 1 — static parity gate (shipped)

tools/check-parity.jsnpm run check:parity. The insight it rests on: every one of the four defects left a statically visible fingerprint in the source. Rendering needs three devices; the fingerprints cost milliseconds.

It does not attempt to prove parity. It refuses the known ways of breaking it, and every rule cites the incident that produced it — a rule with no incident behind it does not belong, because speculative rules are the ones disabled the first time they cry wolf.

RuleRefusesFrom
R1a–etheme.css becoming unreadable to the native CSS parser: an @media block, a bare .dark, a missing .dark:root, a missing @cssInterop flag, or that flag ordered after the variable blocksQRS-201, QRS-206
R2.prettierignore losing the theme.css entry — Prettier lowercases the at-rule and css-interop matches it case-sensitively, so npm run format alone would disable native dark modeQRS-206
R3an unguarded cssInterop() registration (required on web, actively harmful on native)QRS-190 → QRS-203
R4a platform-only visual affordance (android_ripple, underlayColor, a bare elevation:) outside the normalisation seamQRS-207
R5an undocumented Platform.OS branch in the shared UI layerADR-0015
R6a .web.* file with no native siblingthe expo-router/head red-box
R7the build script losing --abi= or --reset-metro, i.e. native verification becoming unreachable againQRS-206

Every rule is mutation-tested. That is not ceremony: R1d initially could not fail, because it searched the whole file for the @cssInterop flag and theme.css documents that flag in its own header — the prose satisfied the rule. A rule that cannot fail is worse than no rule, because it reports safety. Escape hatch is one narrow form, // parity-ok: <reason>; a divergence with a written reason is a decision, a silent one is drift.

Wired at: pre-commit, pre-push (pre-commit is bypassable, and a bypassed commit is the one worth catching), and CI.

Layer 2 — web rendering (exists; extensions specified)

e2e/layout-invariants.spec.ts + e2e/theme-consistency.spec.ts, already running 4 viewports × en/hi and the full 6-combo OS × preference matrix with mechanism-independent coherence assertions. Ceiling: the web bundle only.

Specified additions: axe-core a11y, and screenshot baselines generated on Linux CI only — font rasterisation is per-OS, so a Windows-generated baseline can never match, and one canonical baseline set beats per-developer noise.

Layer 3 — native rendering (shipped for web + Android; iOS job written, unrun)

The layer that would have caught three of the four defects.

The primitive: one probe, three surfaces. Rather than pixel-diffing three platforms against each other — which cannot work, because they legitimately differ in font rasterisation and shadow rendering — the app itself reports the facts that must be identical, and every surface asserts the same text:

route /__parity  (dev + EXPO_PUBLIC_PARITY_PROBE builds only)
  SCHEME=dark
  VAR_SURFACE=#141c24      ← the CSS-variable channel  (useUnstableNativeVariable)
  IMP_SURFACE=#141c24      ← the imperative channel     (useThemeColors)
  COHERENT=true            ← the two channels agree
  TOUCH_MIN=44

COHERENT=false is QRS-201 and QRS-206 — both were the two channels disagreeing. This is the scalable primitive: new invariants are added to the probe once, not three times.

Two implementation findings worth carrying forward, both discovered by building it:

  1. Reading channel B has no cross-platform API, so the READ is platform-split while the REPORTED VALUE is not.useUnstableNativeVariable logs "not supported on web". On native, cssInterop(C, { className: 'style' }) hands the resolved style to the component as a prop — the mechanism that caused QRS-190/203 makes an excellent instrument for detecting them. On web, RNW keeps className as a real CSS class and the browser is the resolver, so the probe asks it (getComputedStyle). Measured before this split: VAR=UNRESOLVED on web.
  2. The verdict is encoded in the testID (parity-coherent-true), not only in text. Both the ids and the text are exposed to the native hierarchy (verified with uiautomator dump on API 34 — an earlier reading that text was absent was a mangled adb path on Git Bash, not a platform limitation). The id is primary because it is stable: an id survives reformatting or translating the line, whereas a text matcher would silently stop matching and leave the native flow passing by never really asserting.

Also fixed during bring-up: the probe initially read the computed style once at mount, before the root layout had applied the theme, and reported VAR=light against IMP=dark — a phantom mismatch. A probe raising false alarms about the bug class it exists to disprove is worse than no probe, so the read is keyed to the resolved scheme.

Verified: web 7/7 (e2e/parity-probe.spec.ts, both preferences × 4 viewports; full suite 454 passing) and Android on an x86_64 emulator — all four token pairs MATCH=true, COHERENT=true, with SURFACE VAR=rgb(20,28,36) IMP=rgb(20,28,36) matching the values independently pixel-sampled while fixing QRS-206. iOS is written but has never run — first execution is the promotion/nightly job, and that run tests the workflow, not the app.

Jobs:

JobRunnerCadenceWhy not every PR
Android emulator + probe + Maestro smokeubuntu-latest (KVM)every PR touching apps/mobile/** or packages/**
iOS simulator + probe + Maestro smokemacos-latestsee the open decision belowmacOS minutes bill at 10× on a private repo
Physical-device passhuman, both nativesevery release, and any systemic-surface changeFree provisioning + real touch input cannot be automated

Layer 4 — periodic audit (specified)

A weekly scheduled workflow that reports, rather than gates: open drift-ledger rows, KNOWN_SMALL_TARGETS size (it must shrink, never grow), routes absent from the e2e ROUTES list, and parity-ok: waivers with their reasons. ADR-0015 already establishes that deferred reconciliation does not work here — so this audit exists to make coverage gaps visible, never to defer a fix that could have been gated.

Consequences

Accepted: three of the four defects would still require layer 3 to catch, and layer 3 is the expensive one — so between now and its landing, the native builds remain a manual step in the Definition of Done, and layers 0–1 exist to make the known recurrences impossible rather than to replace that step. The static layer will also always trail reality: it encodes yesterday's defects. That is the correct trade at ~200ms, provided each new incident adds its rule.

Rejected — cross-platform pixel diffing. Comparing Android/iOS/web screenshots to each other fails on legitimate differences (font rasterisation, shadow models, scrollbar chrome) and produces exactly the flaky, always-red gate that teaches people to ignore gates. The probe asserts semantic facts instead.

Rejected — a pre-commit hook that builds native. A ten-minute pre-commit is bypassed with --no-verify on day two, and then the fast checks in it are bypassed too.

Open decision (blocking layer 3's iOS half)

iOS CI cadence, because it costs real money. This is a private repo on the Free plan: 2,000 Actions minutes/month, and macOS bills at 10× — so one ~12-minute iOS job spends ~120 minutes of quota, i.e. roughly 16 runs/month before overage. Per-PR iOS CI is not affordable at that rate.

Recommendation: Android on every relevant PR (Linux, 1×), and iOS on the develop → uat promotion plus a nightly build — which is where the physical-device pass already happens. This catches iOS-only regressions within a day rather than within a release, at roughly 8–10 runs/month. Recorded as the default; the alternative (paying for per-PR iOS, or self-hosting the Mac mini as a runner, which is free but needs it online and updated) is a cost decision for the product owner.