Status: accepted · Date: 2026-10-31 · Related: ADR-0013, ADR-0019
Enforcement: per-capability tests on the web variant verify the shape now; the native variant files throw NativeOnlyError so a native build cannot silently run against unimplemented backends. scripts/mobile-feature-boundaries.test.mjs keeps the .native / .web variant pattern inside a feature or platform/ folder (never under src/app/).
The Expo app runs today in Expo web and will later run on iOS and Android. Several capabilities exist natively but not on the web (SecureStore, LocalAuthentication, SQLCipher, native camera, volume-key shutter, widgets), and several web APIs stand in for them during development. If each feature branches on Platform.OS inline, the web build silently degrades and the native build drifts from what we have tested; neither is acceptable for a clinical product.
Decision
Each native-only capability lives behind a platform interface, with a web implementation verified now and a native variant filed under the same stem (.native.ts), replacing the throw-stub in a later ticket. The initial set of interfaces (ticket 15):
| Capability | Interface | Web (ticket 15) | Native (ticket 75) |
|---|---|---|---|
| Vault keystore | platform/vault/keystore.ts | record sealed under a non-extractable CryptoKey in IndexedDB (createVaultKeystore); PIN only, no biometrics | expo-secure-store + expo-local-authentication |
| Local store | platform/local-store/local-store.ts | IndexedDB (createIdbLocalStore) | expo-sqlite + SQLCipher |
| WebCrypto provider | @wundklare/crypto provider | globalThis.crypto.subtle | react-native-quick-crypto |
The keystore's native variant (keystore.native.ts) exports the same factory as the base and currently throws NativeOnlyError (platform/vault/native-only.ts); the SQLCipher LocalStore arrives with ticket 75 as a new adapter behind the same interface. The throw references this ADR. Metro picks the .native.ts variant on iOS and Android automatically; the throw turns the capability into a loud build-time failure the first time a native build touches it, which is what we want until ticket 75 lands the real implementation.
Consequences
- The web app ships complete now, with the same interfaces the native app will consume, so no feature code changes when the native ticket lands.
- A capability never silently degrades across platforms; the throw-stub keeps the gap visible.
- The web variant must be production-quality where it is the only variant we have shipped (vault keystore, IndexedDB store, PIN-only unlock). "Dev-only" shortcuts in the web implementation are not acceptable — they would mislead reviewers and users.
Alternatives considered
- Inline
Platform.OSbranches. Rejected: the native branch is unreachable in test and will drift from the interface the web branch evolves to. - A single implementation that forces the native primitive onto web (for example, polyfilled SQLCipher). Rejected: the carrying cost on web is high and the shared implementation locks us out of better native primitives (biometrics, hardware key).
- A noop web implementation that defers everything to native. Rejected: we need Expo web to work in dev, demos, review, and web-first internal tools.