diff --git a/.gitignore b/.gitignore index 25bb823..6c94feb 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,8 @@ functions/.last_import_manifest.json # flutter_map tile cache — created at the project root whenever a widget test # (or the app) renders a PostboxMap. A runtime artifact, never source. -/fm_cache/ \ No newline at end of file +/fm_cache/ + +# Feature worktrees (git worktree add .worktrees/). Local scratch space +# for in-flight branches; never source. +/.worktrees/ \ No newline at end of file diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index d098a2f..4f4a885 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -22,9 +22,10 @@ lands). Their original bodies are preserved below for diff archaeology. | **v1.2** | Intro polish | Done | | **v1.3** | Platform foundations (EU region + observability) | Done | | **v1.4** | Trust & safety (GDPR, anti-cheat) | In flight | -| **v1.5** | Engagement & avatars (social loops + Postie avatar) | Deferred | -| **v1.6** | Collection & content | Deferred | -| **v1.7** | Reach (iOS, localisation, App Check) | Deferred | +| **v1.5** | Resilience & offline play | Queued | +| **v1.6** | Engagement & avatars (social loops + Postie avatar) | Deferred | +| **v1.7** | Collection & content | Deferred | +| **v1.8** | Reach (iOS, localisation) | Deferred | | **v2.0** | Growth (Analytics + experimentation) | Deferred | | **Backlog** | Speculative big-rocks | Speculative | @@ -48,8 +49,8 @@ lands). Their original bodies are preserved below for diff archaeology. Version bumped to `1.2.0+13`. > **Postie avatar (PR #113)** was bundled here originally but has been -> moved down to **v1.5** after Trust & safety. PR #113 is non-draft and -> ready, but will sit waiting through v1.3 and v1.4. **Risk**: long-lived +> moved down to **v1.6** after Trust & safety and Resilience. PR #113 is +> non-draft and ready, but will sit waiting through v1.3–v1.5. **Risk**: long-lived > branch accumulates merge conflicts (especially against `leaderboard_screen.dart`, `friends_screen.dart`, `user_profile_page.dart`, > `startScoring.ts`, `_leaderboardUtils.ts`). Mitigation: rebase #113 > onto `master` whenever any of those files changes. @@ -164,8 +165,11 @@ exceptions in key flows to non-fatals. **Theme**: harden the platform now that we can see what's happening (v1.3 observability). **Ship gate**: account-deletion flow tested end-to-end in staging; impossible-travel detector in shadow mode logging flags but not blocking; Remote Config drives `claim_radius_meters` and `points_by_monarch` end-to-end (client + Cloud Functions) with the safety bounds enforced. -> **App Check enforcement moved to v1.7** (it's gated on iOS `AppleProvider` -> wiring, which lands in v1.7's iOS work). See the App Check item under v1.7. +> **App Check enforcement is now split.** Server-side `enforceAppCheck` on the +> claim callables is a hard *prerequisite* for v1.5's offline work (which starts +> accepting client-supplied timestamps), so the Android half moves **up** into +> **v1.5**. The iOS `AppleProvider` wiring stays in **v1.8** alongside the iOS +> build, since that's what actually gates it. See both items. ### Remote Config for game balance and copy (was #107, moved from v1.3) @@ -226,16 +230,263 @@ Shadow mode first — log flags, no user-facing effect — for 2 weeks; tune thr --- -## v1.5 — Engagement & avatars (Deferred) +## v1.5 — Resilience & offline play (Queued) + +**Theme**: the claim loop is a chain of live callable round-trips, and players hunt +postboxes on foot — lanes, parks, villages — which is exactly where reception fails. +Today there is no offline story at all: no connectivity detection (the app infers +"offline" from `FirebaseFunctionsException.code == 'unavailable'` at three call +sites), no timeouts, no retries, no local cache. Make the flow degrade gracefully on +a bad link, then work outright without one. + +**Ship gate**: a claim whose response is dropped mid-flight is never lost (the player +sees their points, not an empty state); `startScoring` and `nearbyPostboxes` enforce +App Check with a denial rate < 1 %; a claim captured with no signal settles correctly +on reconnect, including across a London-midnight rollover, without disturbing any +other player's leaderboard. + +> Full design (with the attack analysis and the file-level trace) lives in the plan +> that produced this entry. Detail below is deliberately flat, per this doc's rules. + +### Why this is more than a nice-to-have + +Two concrete failures exist today: + +1. **A player can lose a claim they earned.** The claim *write* is idempotent + (deterministic doc ID `claims/{uid}_{box}_{day}`), but the *response* is not. If + the response is dropped, the retry hits the "already claimed today" fast-path in + `startScoring` and the client renders an empty state — no points, no confetti. The + claim landed; the player never sees it. +2. **A player in a notspot cannot play at all.** Every step needs signal. + +Three findings from tracing the flow shape the whole design: + +- **The quiz is 100 % client-side.** `startScoring` accepts only `{lat, lng, clientTsMs}` + and never sees the answer — `quiz_helpers.dart` builds the options from the `monarch` + values `nearbyPostboxes` already returned, and `claim_quiz_sheet.dart` grades locally. + The quiz is flavour, not an authorisation gate, so it is no obstacle to offline play. +- **`startScoring` derives *which* boxes you claimed from position alone** — it runs + `lookupPostboxes(lat, lng, 30)` and claims every unclaimed box in range. So a claim + can be represented as *"I stood here, then"* and adjudicated later exactly as it + would have been live. +- **The travel-speed check would reject a flushed queue.** `enforceTravelSpeedLimit` + compares `Date.now()` against the previous claim's *server* timestamp, and + `_travelSpeed.ts` floors elapsed time at 1 second. Flushing sequentially computes + `speed = 60 × distance`, so any two queued boxes more than ~32 m apart hard-reject + the second. This must be fixed before anything can be flushed. + +### The security problem, and the design that answers it + +Today's travel-speed check is not really an anti-spoof control — the server has always +trusted client-supplied lat/lng, so a GPS spoofer already wins. What it actually is, is +a **serialisation tax**: farming 300 boxes across a city forces a script to space its +calls out in real wall-clock time. + +A naive offline claim deletes that tax. Because the claim ID is `{uid}_{box}_{day}`, an +attacker can save the outbox JSON, add 86,400,000 ms to every capture time, and +resubmit tomorrow — same boxes, full points, from an armchair, forever. The travel-speed +chain validates perfectly, because a uniformly-shifted trace has identical implied speeds. + +**The fix is a server-issued offline capture token.** On any successful online call the +server issues an HMAC-signed token `{uid, issuedAtMs, exp, budget, nonce}` (secret in +Secret Manager). Every offline capture carries it, and at flush the server enforces +`capturedAtMs >= token.issuedAtMs`, `capturedAtMs <= serverNow`, and single-use nonce +consumption against `budget`. Both bounds are server-attested, so the whole offline +timeline must fit inside a wall-clock window the server actually observed — you cannot +submit an 8-hour synthesised walk five minutes after going offline. That restores the +serialisation tax, which is the property that matters. + +Chaining capture times is a *consistency* control, not a security control. The token is +what does the work. + +### Prerequisites (both nearly free) + +- **`enforceAppCheck: true` on `startScoring` and `nearbyPostboxes`.** App Check is + activated client-side but **no callable enforces it** — `enforceAppCheck` appears + nowhere in `functions/src`. One options object per function on `firebase-functions` v2. + Highest-leverage anti-abuse control available, and it matters far more once an endpoint + accepts client-supplied timestamps. (iOS `AppleProvider` wiring stays in v1.8.) +- **`allow read: if false` on `/postbox`.** `firestore.rules` currently lets any signed-in + user read every postbox `geopoint`, and **no Dart or Kotlin code reads that collection** + — everything goes through the callables. So the fuzzy compass is presently a UI + convention, not a security property. Closing the rule costs nothing and makes the + on-device cache below a deliberate, bounded exposure rather than a formality. + +### Phase 1 — Survive a flaky link + +Ships alone and fixes the lost-claim bug. The common real failure is *"one bar, request +hangs, I lose my quiz sheet and have to walk back and rescan"* — not "no signal all walk". +Most of the user value is here. + +- **`attempts/{attemptId}` idempotency doc** — `{uid, status, result, expiresAt}`. + Transaction at the top of the handler: `done` → return the stored result verbatim; + `in_progress` → throw `aborted`; else create. One doc read by ID, no index. Beats a + `claims`-query approach, which would double-credit across a midnight boundary and + cannot represent a *zero-claim* attempt (`found: false` / `allClaimedToday` write no + claim doc — exactly the responses worth replaying). Firestore native TTL on `expiresAt`. +- **Callable timeouts + jittered retry** on `unavailable` / `deadline-exceeded`, added at + the `appFunctions` chokepoint (`lib/firebase_functions_eu.dart`). **Must land after the + attempts doc** — a shorter timeout *increases* the rate of "the server may or may not + have processed this", so retry without idempotency is retrying a non-idempotent write + against an uncertain outcome. +- **Stop destroying sheet state.** `claim_quiz_sheet.dart` calls `_cancel()` on every + network failure, tearing the sheet down; the claim path strands the user mid-quiz behind + a SnackBar. Add a `networkError` stage with a Retry that preserves the scan + quiz state. +- **`Geolocator.getLastKnownPosition()` fallback** when the 30 s fix times out — currently + never used anywhere in the app. +- **Connectivity service** (`connectivity_plus`) exposing a `ValueListenable`; an + `OfflineBanner` cloned from `maintenance_banner.dart` and mounted beside it in `home.dart`. + More Postman James offline lines — there is currently exactly one (`errorOffline`). +- **Cached reads** — Firestore persistence is *already on by default* on Android/iOS, so the + work is `Source.cache` fallbacks and `isFromCache` staleness chips on History / + Leaderboard / Friends, not "enabling persistence". + +### Phase 2 — A backend that can accept a claim made in the past + +Server-only; nothing user-visible ships here. + +- **Extract `_claimCore.ts`** — the scoring write path lifted out of `startScoring`, which + becomes a thin live wrapper. Single source of truth, in the spirit of `_routePlanner.ts`. +- **Add `eventTime`; don't redefine `timestamp`.** `timestamp` stays the immutable + server-write time (audit trail; `abuse.ts` reads it). `eventTime` is when the user was + physically there. Travel-speed orders by `eventTime`. + ⚠️ **Deploy hazard**: Firestore *excludes documents missing the `orderBy` field*. Every + existing claim lacks `eventTime`, so the neighbour query returns empty for every user and + `enforceTravelSpeedLimit` **silently no-ops for the entire user base** until each user's + first post-deploy claim. A backfill (`eventTime = timestamp`, reusing the + `WRITE_BATCH_SIZE = 400` pattern in `_recomputeScores.ts`) must land **before** the query + switches over. +- **Two-sided neighbour travel-speed check.** A claim at time `t` must be plausible against + both the nearest stored claim *before* `t` and the nearest *after* it. One-sided ("reject + anything older than your newest claim") permanently strands a legitimate claim: capture + t1 and t2 offline → flush t1 → network dies → make a live claim at T → retry t2 where + t2 < T → rejected forever. Two-sided *inserts* into the timeline instead, and catches + backdating a capture to just before a known-distant live claim. + - **Process batch items sequentially, not `Promise.allSettled`** — earlier items must be + visible to later items' neighbour queries. The `allSettled` reflex in `startScoring` is + the trap; used across items, the chain isn't validated at all. + - Don't change `checkTravelSpeed`'s signature — a substantial pinned suite depends on it. + - The 1 s floor becomes dangerous with client times (an NTP resync mid-outbox yields a + zero delta, and a 40 m gap reads as 2400 m/min — spuriously rejecting an honest user). + Reject non-monotonic sequences server-side, **and** derive `capturedAtMs` client-side + from a monotonic anchor (`Stopwatch` elapsed + wall clock at flush). +- **Offline capture token** — issue on `nearbyPostboxes` / app start, verify at flush. +- **Fix the uniqueness query.** It currently asks for claims with `dailyDate < today`, which + for a backdated claim misses a *later* claim on the same box and **double-counts a unique + postbox**. Replace with a `.count()` on `(userid, postboxes)`; the existing 3-field index + covers the prefix. +- **`lookupPostboxes` needs a `today?` parameter** — it calls `getTodayLondon()` internally + and derives `claimedToday` from it, so adjudicating a backdated claim computes the + response against the wrong day. +- **`streakFromClaimDays(days, today)`** — a new pure helper. `computeNewStreak` is + forward-only and cannot handle out-of-order days: a live claim on Wed landing *before* a + Tue-dated flush resets the streak to 1, with no forward-only repair. Flush path only; the + live path keeps the cheap incremental version. This is what turns a late flush into a + **saved streak** — the moment that makes the feature feel good. +- **`flushOfflineClaims` callable**, sharing `_claimCore`. Must **re-check maintenance mode + server-side** — `MaintenanceGuard` is client-only, so an offline user can queue during + maintenance and flush into a mid-migration server. +- **New shadow abuse signals**: `queuedForMs`, `batchSize` / `batchSpanMs`, and **speed + variance across the batch** (a synthesised trace has near-constant implied speed; a human + doing a postbox round stops, waits, gets a coffee). Derive the `offline` flag server-side + from which handler ran — never from client payload, or it becomes a self-declared + exemption from a security signal. Don't exempt offline claims from `outOfWindowSignal`; + re-point it at a `flushClientTsMs` so it still catches a tampered device clock, which is + precisely the attack this phase opens. +- **Remote Config**: `kill_switch_offline_claims`, `offline_claim_grace_hours` (~36), + `max_offline_claims_per_day`. Pairs with the v1.4 game-balance Remote Config work. + +**Backdating needs no per-day leaderboard archive.** With `updateUserLeaderboards` still +called with the **real** today, its weekly/monthly range queries pick up a backdated claim +automatically while its exact `dailyDate == today` daily query correctly excludes it; and +`maxDailyFromClaims` already buckets by `dailyDate`. Claims, lifetime, county, weekly, +monthly, `maxDailyPoints`, unique count and streak all get credited. Only a *closed* daily +board doesn't — and no UI can display one. + +⚠️ **The landmine**: never pass a backdated `today` to `updateUserLeaderboards`. It would +see a `periodKey` mismatch, set `existing = []`, and `tx.set(..., { merge: false })` — +**wiping the daily leaderboard for every player**, stamped with yesterday's key. Silent +cross-user data loss. Deserves a dedicated test. + +### Phase 3 — Offline claiming, warm path + +The high-value, low-risk half. + +- Cache the last `nearbyPostboxes` payload — it contains **no coordinates** (`applyUserClaims` + strips them), so this leaks nothing — alongside a server-issued, position-bound, + short-lived `scanId`. An offline claim requires `scanId` + freshness + proximity to the + scan position. +- **`scanId` *is* the offline capture token, for free** — the server has already told this + user a box is here, so the abuse delta is small. +- Full quiz preserved: same options, same grading, same feel. This is the "signal died + mid-claim" rescue, and it is the case that actually happens. +- `claim_outbox.dart` (queue as JSON in `shared_preferences`) + `outbox_sync.dart` (flush on + connectivity-restored / app-resume / manual "Send now", reconciling results honestly into + the UI and James). + +### Phase 4 — Blind capture and offline discovery (Conditional — do not start by default) + +**Explicitly gated on evidence.** This phase carries most of the attack surface and the +least payoff: no quiz, no feedback at the box, and it is what forces postbox coordinates +onto the device. **Ship Phases 1–3, measure, and only build this if the warm path +demonstrably fails to cover real usage.** If Phases 1–3 land well, the right outcome may be +to drop it and move the pieces to Backlog. + +If it does go ahead, it ships behind `kill_switch_offline_claims`, with a low daily cap and +gated on `trustScores`: + +- **Blind capture ("post it later")** — the player stands at a box and taps; the app banks + `{position, capturedAtMs, token}`. The server adjudicates on flush and reports back. + Requires zero postbox data on device. +- **Offline discovery** — a `prefetchArea` callable (bounded radius, hard cap on boxes, + rate-limited via the transactional-counter pattern in `reports.ts`), an app-private + `postbox_cache.dart`, and a `local_scan.dart` mirroring `lookupPostboxes` on-device + (geohash cell + neighbours, distance filter, 16-wind compass) against `_geo.ts`'s + `setPrecision`. Dart's `latlong2` won't agree with `geolib` to the metre, so keep the + local radius slightly permissive (~35 m) and let the server remain the judge on flush. +- **Product guardrail**: the offline UI stays the fuzzy compass and scan. The cache is an + implementation detail reproducing the existing experience offline; it must never become a + map-of-all-pins surface. That — not the rules file — is what preserves the game. + +### Deliberately cut + +- **A pre-midnight "you have unposted claims" notification.** It fires precisely when the + user has no signal and therefore cannot act, converting a silent loss into an announced, + unavoidable one. Strictly worse than saying nothing. +- **A per-day leaderboard archive.** Unnecessary — see the backdating note above. + +### Drive-by bug found while tracing the claim path (fix in this release) + +`android/app/src/auto/kotlin/com/code418/postbox_game/car/ClaimAction.kt` calls +`FirebaseFunctions.getInstance()` with **no region**, so it targets `us-central1` while +every function deploys to `europe-west2` (`_region.ts`). The CI guard +`test/firebase_functions_region_test.dart` only scans `lib/`, so Kotlin call sites are +invisible to it — **the Android Auto claim button may simply not work**. Confirm against +the live deployment, pin the region, and extend the guard test to cover Kotlin sources. + +### Known test breakage (decide deliberately, don't discover in CI) + +- `test/cross_language_sync_test.dart` reads `functions/src/startScoring.ts` and regexes + `CLAIM_RADIUS_METERS`. Moving that constant into `_claimCore.ts` fails the anchor + assertion hard — and a re-export doesn't match the regex either. Keep the literal + declaration in `startScoring.ts`, or update the test's path. +- `test.index.ts` imports `dailyClaimPatch` from the `startScoring` module, and pins + `checkTravelSpeed`'s signature. +- Extend the `updateUserLeaderboards` mock-Firestore suite with the "a backdated `today` + must not clobber the daily board" case — highest-value new test in the project. + +--- + +## v1.6 — Engagement & avatars (Deferred) **Theme**: build the daily-return loop and the friend-pressure loop on top of the now-stable v1.4 platform — and ship the visible-identity work (Postie avatars) so users have something to flex with on the new live leaderboards. **Ship gate**: DAU / WAU lift measurable in Analytics; POTD claim rate ≥ 30 % of DAU; weekly recap engaged-with by ≥ 25 % of recipients; avatar coverage > 50 % of WAU within two weeks of release. ### Postie avatar creator + surface across app (was #113, originally v1.2) -Moved here after v1.4 Trust & safety. PR #113 is non-draft and fully tested +Moved here after v1.4 Trust & safety and v1.5 Resilience. PR #113 is non-draft and fully tested (`flutter analyze` clean, 98 + 227 tests passing); blocked only on manual QA -+ deploy. Avatars surface naturally on the v1.5 engagement surfaces (live ++ deploy. Avatars surface naturally on the v1.6 engagement surfaces (live leaderboards, friend cards, profile headers), so bundling them is the right pairing. @@ -246,14 +497,14 @@ right pairing. Pre-merge: -1. Rebase #113 onto `master` (it has been sitting through v1.3 and v1.4 — expect conflicts in `leaderboard_screen.dart`, `friends_screen.dart`, `user_profile_page.dart`, `_leaderboardUtils.ts`, `startScoring.ts`). +1. Rebase #113 onto `master` (it has been sitting through v1.3–v1.5 — expect conflicts in `leaderboard_screen.dart`, `friends_screen.dart`, `user_profile_page.dart`, `_leaderboardUtils.ts`, `startScoring.ts`, which v1.5 splits into `_claimCore.ts`). 2. Run the manual checklist: Settings → Your postie → cycle parts → Save → confirm avatar shows in Friends list, both Leaderboard tabs, Profile header. Re-open creator → confirm saved state loads. Claim a postbox → confirm new leaderboard entry carries the avatar. 3. `firebase deploy --only firestore:rules,functions`. 4. Merge. Long-tail follow-ups (do as separate PRs after avatars land): -- Avatar unlock tiles tied to v1.6 streak rewards (e.g. a "Centurion" hat at 100-day streaks). +- Avatar unlock tiles tied to v1.7 streak rewards (e.g. a "Centurion" hat at 100-day streaks). - Avatar appears in v2.0 BigQuery export as a cohort dimension (no PII — just style choices). ### Live leaderboard with overtake animations (was #104) @@ -296,7 +547,7 @@ Progress: Firestore trigger `onClaimCreated` updates active challenges. FCM on i --- -## v1.6 — Collection & content (Deferred) +## v1.7 — Collection & content (Deferred) **Theme**: turn the claim loop into a collection loop. Most users today see a points number; few have a sense of *which* cyphers they've seen or what's @@ -405,11 +656,11 @@ No backend changes — `nearbyPostboxes` already returns enough metadata for the --- -## v1.7 — Reach (iOS, localisation) (Deferred) +## v1.8 — Reach (iOS, localisation) (Deferred) **Theme**: open the door to users outside the current Android-phone + Wear OS + Android Auto + (eventual) XR funnel. -**Ship gate**: signed iOS build available on TestFlight; at least one non-English locale ships and is selectable in Settings; no English-language strings remain in user-facing widgets per `flutter_lints` rule; App Check enforced server-side with denial rate < 1 % in monitor mode. +**Ship gate**: signed iOS build available on TestFlight; at least one non-English locale ships and is selectable in Settings; no English-language strings remain in user-facing widgets per `flutter_lints` rule; App Check `AppleProvider` attesting on iOS with denial rate < 1 % (the Android half already enforced in v1.5). ### iOS support (new) @@ -417,25 +668,26 @@ CLAUDE.md notes `firebase_options.dart` has iOS config but no `Podfile` exists. - `cd ios && pod install` (generates the Podfile). - Verify `Info.plist` permissions: `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, `NSCameraUsageDescription`, `NSPhotoLibraryUsageDescription` (last two already present per CLAUDE.md). -- Wire `AppleProvider` (`DeviceCheck` / `AppAttest`) for App Check (see the App Check enforcement item below — the two land together in v1.7). +- Wire `AppleProvider` (`DeviceCheck` / `AppAttest`) for App Check (see the App Check item below — server-side enforcement already landed in v1.5, so iOS clients will be *rejected* until this is done; it is a launch blocker, not a nice-to-have). - App Store Connect listing: name, screenshots, age rating, privacy nutrition labels (matching what the GDPR plan in v1.4 implements). - Mac Catalyst evaluation — `firebase_options.dart` already has a macOS config; trivial scope or skip. Smoke-test gates: login (email + Google), nearby scan, claim quiz, report submission with photo, route mode end-to-end. Wear/Android Auto are not relevant on iOS. -Backend risk: callable region pinning (v1.3, done) and App Check enforcement (a sibling v1.7 item below) both touch iOS — wire `AppleProvider` as part of the App Check work. +Backend risk: callable region pinning (v1.3, done) and App Check enforcement (v1.5, done) both touch iOS. Because v1.5 turns on `enforceAppCheck` server-side, an iOS build without `AppleProvider` will fail every callable — wire it before the first TestFlight drop. + +### App Check — iOS provider + remaining surfaces (was #96; Android half done in v1.5) -### App Check enforcement audit and hardening (was #96, moved from v1.4) +**v1.5 already turned on `enforceAppCheck` for the claim callables** (it was a prerequisite for accepting client-supplied timestamps). What remains here is the iOS provider and the non-Functions surfaces. -App Check is configured client-side for Android release (`AndroidPlayIntegrityProvider`). Audit and **enforce** server-side. Moved here from v1.4 because iOS `AppleProvider` wiring is part of v1.7's iOS work, so the two are best done together. +1. iOS: activate `AppleProvider` (`DeviceCheck` / `AppAttest`). **Launch blocker** — with server-side enforcement already live, an iOS build without it fails every callable. +2. `AndroidDebugProvider` for dev — token committed to developer machines, not the repo. +3. Extend enforcement from the claim callables to the remaining ones, plus Firestore, Storage, RTDB in the Firebase Console. +4. Cloud Monitoring alert on App Check denial-rate spikes. -1. `AndroidDebugProvider` for dev — token committed to developer machines, not the repo. -2. iOS: activate `AppleProvider` (`DeviceCheck` / `AppAttest`) once iOS builds are wired up (the iOS support item above). -3. Firebase Console: enforce on Cloud Functions, Firestore, Storage, RTDB. -4. Functions code: every callable rejects with `failed-precondition` if `context.app` absent (defence-in-depth on top of platform enforcement). Wrap in `functions/src/_appCheck.ts`. -5. Cloud Monitoring alert on App Check denial rate spikes. +Roll each new surface out in **monitor** mode for 7 days, then **enforce** if denial rate < 1 %. Keep a break-glass Remote Config flag to disable explicit checks during provider outages. -Roll out in **monitor** mode for 7 days, then **enforce** if denial rate < 1 %. Keep a break-glass env var to temporarily disable explicit `context.app` checks during provider outages. +⚠️ **Known risk carried from the Android Auto work**: the off-Play `auto` flavour uses `AndroidPlayIntegrityProvider`, which expects Play distribution. Confirm how it behaves under enforcement before v1.5 ships, or that flavour's claim button breaks. ### Localisation infrastructure (new)