Skip to content

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, and reconcilePrerenderedAssetOrigins() (see below). Verified via the real WebView inspectors (iOS WebKit ios-webkit-debug-proxy; Android Chrome DevTools Protocol): default = staging (Umami 6e7ddfce, assets chipi.github.io, analytics POST → 200); real button tap → localStorage.targetEnv='prod'; relaunch → prod (Umami 4a25d8da, assets www.orrerylearn.com, analytics POST → 200). @sentry/capacitor native crash reporting active on both (logcat breadcrumbs) — sends via the native transport (not a WebView fetch), so it follows the same targetConfig() tier by construction.

Findings during verification: (1) the prerendered landing hero did NOT switch — assetOrigin is 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-only reconcilePrerenderedAssetOrigins() called from the layout mount, which rewrites prerendered image origins to the active tier. (2) localStorage flush nuance: iOS simctl terminate flushes WKWebView storage cleanly so the flip persists; Android am force-stop kills 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 (assetUrl origin 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 repointsStagingProd
Asset / image + locale streaming (STREAM_ORIGIN, ADR-079)chipi.github.io/orreryorrerylearn.com
Sentry (DSN → project, environment)project 6 · environment=stagingproject 4 · environment=prod
Umami (PUBLIC_UMAMI_WEBSITE_ID)staging siteprod 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 Vite define, set by an env var at build like STREAM_ORIGIN): true for internal/simulator/TestFlight builds, false for 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.
  • Selection storage: a localStorage key orrery.targetEnv ('staging' | 'prod'), reusing the ADR-016 client-storage carve-out already set by orrery.qualityTierno new dependency (@capacitor/preferences is 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 / resolveLocaleBundleOrigin resolve the active origin from orrery.targetEnv instead of the single baked STREAM_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/capacitor native 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/preferences for storage — deferred: a new dep for a value localStorage already 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).

Orrery — architecture documentation · MIT · No tracking