ADR-0048: Mobile version skew — server-driven minimum-version gate + backward-compatible API window

Status

Accepted (pending FieldForce Phase 1 implementation)

Tags

fieldforce, digital-worker, mobile, flutter, versioning, compatibility, forced-upgrade, api, release, powersync

Decision

Mobile clients cannot be redeployed like the web clients: store review, staggered rollout, and field devices that update late mean every released app version stays alive in the field for weeks. The workspace therefore ships a version-skew policy from day one:
  • Every API request carries X-App-Id, X-App-Version, X-Platform headers, so the backend always knows which client versions are live (adoption measurement) and can reject dead ones.
  • The Go backend exposes an unauthenticated GET /api/mobile/config returning, per app: min_supported_version, latest version, and store URLs. The app fetches it at startup and on foreground resume, and caches it locally. It is pre-auth by necessity (the gate must fire before login) and therefore must never carry secrets or per-user data.
  • Below minimum while online → blocking upgrade screen with a store link. As a backstop, the API rejects below-minimum clients with HTTP 426 Upgrade Required, which the client maps to the same screen — defense in depth for clients that missed the config fetch.
  • The gate fails open offline. It enforces only on a fresh (online) config read. Offline, cached data stays usable; if the last-known config says the version is below minimum, the app shows a persistent “update required” banner but does not block. Blocking offline would strand a worker who cannot download an update anyway.
  • API compatibility window: the Go API remains backward-compatible with every app version ≥ min_supported_version. Breaking-change procedure: ship the tolerant/additive server change first → raise min_supported_version → wait for adoption (measured via the version header) → remove the old behavior. The same additive-evolution rule applies to the PowerSync client schema and Sync Rules while old versions are live.

Why

The one property mobile does not share with the rest of the Gremlin stack is deployment control. Web and backend deploy atomically; the mobile fleet is a long tail of old binaries the team does not control. Without a skew policy, the first breaking API or sync-schema change after launch produces undefined behavior on every un-updated field device — and the fix (a min-version gate) cannot be retrofitted, because the old binaries already shipped without the check. That is why this is a day-one foundation piece, not a later hardening task. The must-record nuance is the fail-direction split between gates. The background-location flag fails closed when its state is unknown (ADR-0043) because it protects privacy — the safe default is off. The version gate fails open offline because it protects contract compatibility, and the protection has a server-side backstop: the moment the device is back online, the API’s 426 rejection applies regardless of what the stale cache said. Hard-locking an offline worker over a version check would destroy offline-first (the same reasoning as offline auth grace, ADR-0041) while protecting against nothing — an offline device isn’t talking to the API it might be incompatible with. Rejected alternatives:
  • No gate; rely on store auto-update. Adoption has a long tail; one breaking change strands old clients in undefined behavior. Rejected — the gate is cheap and cannot be added retroactively to shipped binaries.
  • Force upgrade on every release. Hostile UX, kills staged rollouts, punishes users for the team’s release cadence. Rejected — the minimum rises only when a contract change requires it.
  • URL-versioned API (/v2/...) for mobile. Correct for third-party consumers, but heavier than needed when all clients are first-party: additive evolution + a minimum-version window achieves compatibility with far less surface. Rejected for now; revisit if third-party clients ever appear.
  • Fail-closed offline gate. Strands offline workers with no remediation path. Rejected — banner offline, block online, 426 as backstop.
  • Shorebird code push as the skew answer. Helps ship Dart-only fixes fast but does not cover native/store-level changes; it reduces skew pressure, it is not a compatibility strategy. Stays a deferred option (ADR-0037/0047).

Consequences

  • Two small v1 UI pieces: the blocking upgrade screen and the offline “update required” banner.
  • The Go backend gains the /api/mobile/config endpoint, the version-header middleware, and the 426 rejection path; raising min_supported_version is a deliberate, recorded operational act (config change), never an accident.
  • Version headers give free adoption metrics — the input for deciding when a raise is safe.
  • Contract changes acquire a procedure (additive → raise → adopt → remove) that agents and reviewers can check against; “just change the endpoint” is no longer a valid mobile-facing change.
  • PowerSync schema/Sync Rules changes inherit the same constraint: additive while old clients live.

Rules for agents

  • Every mobile API request carries X-App-Id / X-App-Version / X-Platform.
  • NEVER make a breaking change to a mobile-facing API or the PowerSync schema for versions ≥ min_supported_version. Breaking changes follow: additive server change → raise minimum → adoption → removal.
  • The version gate blocks only on a fresh online config read or an HTTP 426; offline it shows a banner and NEVER hard-blocks.
  • Map HTTP 426 from any endpoint to the upgrade screen.
  • /api/mobile/config is unauthenticated: no secrets, no per-user data in it.