Context
Commerce, Mezzora and Jute need receipt printers, cash drawers, scales and card readers on phones, tablets and Android tills, which only vendor SDKs (Epson, Star, Sunmi, PAX, Stripe Terminal, scale makers) reach. 0345 decision 6 gave an app asrc/native-sources/<surface>/ group for hand-written Swift and Kotlin, and 0458 and 0459 put the Sunmi and PAX
Kotlin there, inside the till app. That ties vendor SDKs to one app, repeats them in every app that needs the same
device, and leaves native code that no package test, version or release covers. The alternatives were a native
rewrite of the apps (rejected: React Native and Expo stay the mobile stack) and a separate Swift Package Manager,
CocoaPods or Maven publish per device (not needed while every consumer is an Expo app).
Decision
Principle: Native code lives only in Expo module packages (src/, ios/, android/, plugin/), one package
per capability, behind a TypeScript port with a mock driver. Apps contain no hand-written native code.
- React Native and Expo stay the mobile stack. Swift and Kotlin are written only inside Expo module packages, never as a rewrite of an app or a screen.
- One capability, one package. Each hardware or OS capability is one self-contained Expo Modules API package:
src/(the TypeScript side: the module binding and the drivers),ios/(Swift, holding the vendor SDKs),android/(Kotlin, holding the vendor SDKs),plugin/(the package’s config plugin: permissions,Info.plistand manifest entries, and the build-time removal of vendor families an app’s runway does not list, as 0442 decision 3 requires) andexpo-module.config.json. Autolinking brings the native code into CocoaPods and Gradle at prebuild. A package that needs only a library Expo already ships (the camera,expo-glass-effect,@expo/ui) uses it and writes no native code. - Apps hold no hand-written native code. An app’s
ios/andandroid/folders are written byexpo prebuildand never edited by hand; an app has no Swift or Kotlin folder. This replaces 0345 decision 6: thesrc/native-sources/<surface>/group is retired, and a watch screen, an intent body or any other hand-written native surface is an Expo module package of its own. It narrows 0345 decision 7: a module package authors its own config plugin inplugin/; members still author no other config plugin, and gen’s one plugin still holds every Stackra modification of an app. - Vendor SDKs only in the module. A vendor SDK is declared only in that module’s podspec and Gradle file, fetched from the vendor’s repository at build time under the vendor’s terms; this repository holds no vendor binary or source (0458 decision 6 and 0459 keep applying).
- Port and driver. The ports stay where they are:
ReceiptPrinter,CashDrawer,Scale,BarcodeScanner,CardTerminaland the others in@stackra/peripherals/contracts(0253, 0339), injected by type, unchanged. A module package ships one driver per vendor behind its port (print(receipt),openDrawer()), and thememorydrivers of@stackra/peripheralsare the mock that lets products and tests run without hardware. A call that no device can honour resolves the typed refusal ofCapabilityResult, never an exception (0253). This replaces 0442 decision 4’s./nativeentry of@stackra/peripherals: native drivers live in the capability’s module package, and the Sunmi and PAX drivers and Kotlin of 0458 and 0459 move out of the till app into the printer, drawer and scanner packages, split by capability. The module stays a pass-through; mapping vendor answers to refusals stays in the TypeScript driver. - Placement and names. The packages live in the Stackra monorepo beside
@stackra/peripherals, whose ports they implement:packages/experience/<name>, named@stackra/native-<capability>(native-receipt-printer,native-cash-drawer,native-scale,native-card-terminal). Device identity and telemetry stay in@stackra/devices(0235) and device registration in OS devices (STA-725). - Published like every package. They publish to the Stackra registry (npm.stackra.figentra.com) with the other
packages; the tarball carries
ios/,android/,plugin/andexpo-module.config.json. There is no separate Swift Package Manager, CocoaPods or Maven publish. If a pure-native app ever needs one, the native core splits into its own library and the Expo module becomes a thin wrapper over it. - First modules. For Commerce, Mezzora and Jute: receipt printer, cash drawer, scale and card terminal (the owner’s
“card reader”: the existing
CardTerminalport; each acquirer SDK is still its own ADR, 0442 decision 6). Barcode scanning uses the camera modules first and becomes@stackra/native-barcode-scanneronly when a built-in scan head (Sunmi, PAX) needs its SDK. - Other native surfaces follow the same rule. Widgets, Live Activities, App Intents and Liquid Glass use Expo’s
own libraries (the Expo widget library,
expo-glass-effect,@expo/ui); hand-written Swift or Kotlin beyond them is an Expo module package under this ADR.
Consequences
- One printer, drawer, scale or card-terminal package serves every app; a vendor SDK update is one package release.
- The native code gets a version, a changeset, tests of its TypeScript drivers and a registry release; the Swift and Kotlin themselves are checked on the box and on certified models (STA-739), not on a developer Mac.
- The till template loses its native-sources folders; the Sunmi and PAX Kotlin move in the printer, drawer and scanner batches.
- Each module package now owns a config plugin, so a plugin bug is a package bug with a package test.
- Chore: the workspace-layout rule gains this principle (
.agents/rules/workspace-layout.md, rendered into AGENTS.md bypnpm agents:sync), done by the integration batch because rules are a shared file.
Enforcement
checkNativeModulesin@stackra/repo-checks(thenative-modulesrule ofpnpm validate): no app member holds.swift,.kt,.java,.mor.mmfiles outside its generatedios/andandroid/(the mobile app’s customer display is exempt until STA-1533 moves it), and a package withexpo-module.config.jsonhassrc/,plugin/andios/orandroid/in git and lists them infiles. The root.gitignorere-includespackages/*/*/ios/andpackages/*/*/android/.- planned — review by
architecture-guardianuntil the check lands.