ADR-083 — Mobile runtime environment switcher (internal builds only; release is prod-locked)
Status · Accepted — implemented + verified end-to-end on iOS simulator AND Android emulator (2026-07-25). Built:
src/lib/target-env.ts(+ tests),__MOBILE_INTERNAL__define, asset-url/sentry/analytics wiring,TargetEnvSwitcher.svelte, andreconcilePrerenderedAssetOrigins()(see below). Verified via the real WebView inspectors (iOS WebKitios-webkit-debug-proxy; Android Chrome DevTools Protocol): default = staging (Umami6e7ddfce, assetschipi.github.io, analytics POST → 200); real button tap →localStorage.targetEnv='prod'; relaunch → prod (Umami4a25d8da, assetswww.orrerylearn.com, analytics POST → 200).@sentry/capacitornative crash reporting active on both (logcat breadcrumbs) — sends via the native transport (not a WebView fetch), so it follows the sametargetConfig()tier by construction.Findings during verification: (1) the prerendered landing hero did NOT switch —
assetOriginis a module const baked into prerendered HTML and Svelte doesn't re-evaluate a non-reactive const on hydration (client-navigated pages were already correct). Fixed with an internal-onlyreconcilePrerenderedAssetOrigins()called from the layout mount, which rewrites prerendered image origins to the active tier. (2) localStorage flush nuance: iOSsimctl terminateflushes WKWebView storage cleanly so the flip persists; Androidam force-stopkills the process before the WebView flushes, losing the flip — a real relaunch backgrounds first (onPause→ flush), which persists it. Test/verify accordingly. Date · 2026-07-24 Related ADRs · ADR-082 (telemetry env ladder — the tiers this switches between), ADR-079 (assetUrlorigin spine /STREAM_ORIGIN), ADR-078 (mobile stream-heavy bundle), ADR-016 (no client storage — the carve-out this reuses)
Context
ADR-082 made the mobile app a segment of the dev→staging→prod ladder, with the tier baked at build time (PUBLIC_SENTRY_* / PUBLIC_UMAMI_* / STREAM_ORIGIN via Vite define). To switch what a build targets you must rebuild + reinstall. For a testing loop that is too slow: the operator wants to flip an installed app between staging and prod on the fly to see whether observability + content land in the right place — without a rebuild.
But a runtime switch must never reach an App Store user: flipping a shipped app to staging/dev would pollute the prod dataset and expose internal endpoints.
Decision
Internal builds (simulator / TestFlight / any non-release) ship a runtime "target environment" switcher. App Store release builds are prod-locked — no switcher, only prod config baked. One selection moves all three coupled concerns together, so "target = staging" means one coherent thing:
| The switch repoints | Staging | Prod |
|---|---|---|
Asset / image + locale streaming (STREAM_ORIGIN, ADR-079) | chipi.github.io/orrery | orrerylearn.com |
Sentry (DSN → project, environment) | project 6 · environment=staging | project 4 · environment=prod |
Umami (PUBLIC_UMAMI_WEBSITE_ID) | staging site | prod site |
On-device tiers are staging + prod only — NOT dev. The dev tier lives on the tailnet host homelab (ADR-082), which a device/simulator off the tailnet can't resolve; dev stays a vite dev-in-browser concern.
Design (spec for the iOS thread)
- Build flag
__MOBILE_INTERNAL__(new Vitedefine, set by an env var at build likeSTREAM_ORIGIN):truefor internal/simulator/TestFlight builds,falsefor App Store release. It is the single gate. - Config shape:
- Internal build bakes an all-tier config map —
{ staging: {origin, sentryDsn, sentryEnv, umamiId}, prod: {…} }— plus the switcher UI. - Release build (
__MOBILE_INTERNAL__ === false) bakes only prod as a single constant; the switcher code + staging config are tree-shaken out. An App Store user has no path to another tier.
- Internal build bakes an all-tier config map —
- Selection storage: a
localStoragekeyorrery.targetEnv('staging' | 'prod'), reusing the ADR-016 client-storage carve-out already set byorrery.qualityTier— no new dependency (@capacitor/preferencesis an alternative but would need approval). Default for internal builds: staging (safe sandbox); release is hard-prod. - What reads the selection:
assetUrl.ts(ADR-079 spine):resolveAssetOrigin/resolveStreamedUrl/resolveLocaleBundleOriginresolve the active origin fromorrery.targetEnvinstead of the single bakedSTREAM_ORIGIN, when__MOBILE_INTERNAL__.sentry.ts/analytics.ts: read the active tier's DSN/env/umami-id from the map.
- Re-init on switch:
- Telemetry JS layer re-inits cleanly with the new config.
- Caveat — native crash sink:
@sentry/capacitornative handlers init once at startup; a runtime flip moves the JS/web-layer reporting immediately, but the native sink likely needs an app relaunch to fully rebind. The switcher should say "relaunch to fully apply native crash routing." (Verify on-device.) - Assets already loaded + SW-cached under the old origin stay cached; new fetches use the new origin. Note for testing (a hard reload / cache clear may be wanted).
- UI: a control in the in-app DebugPanel (
?debug=1) or a dedicated internal settings row — rendered only when__MOBILE_INTERNAL__.
Alternatives considered
- Build-time per tier (no in-app switch) — rejected: the rebuild+reinstall loop is exactly the friction that motivated this. (It remains the fallback and is what ADR-082 already supports.)
- Runtime switch in the release build too — rejected: an App Store user could flip to staging/dev, polluting prod data and exposing internal endpoints.
@capacitor/preferencesfor storage — deferred: a new dep for a valuelocalStoragealready handles under the existing ADR-016 carve-out.
Consequences
- Internal builds carry all-tier public DSN/site-ids (safe — they ship in any bundle by design). Release carries only prod.
- Touches:
vite.config.ts(the__MOBILE_INTERNAL__define + all-tier config injection),asset-url.ts,sentry.ts,analytics.ts, and a new switcher UI. - Implementation is deferred to the iOS thread (needs Xcode + a device to prove the native re-bind + real cross-tier routing). No GitHub issue opened without operator approval — tracked here + in the mobile guide until then.
Not covered / out of scope
- The dev tier on-device (unreachable off-tailnet — staging/prod only here).
- Native crash symbolication (App Store Connect / Play Console — ADR-082 amendment / the #428 note).