Closer/ClaudeReport.md

126 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Claude QA Report — Full-App QA (living report)
> **RUN-STATE: Round 3 (full re-QA, started 2026-06-25) | Build == HEAD `ce7fc2e` (rebuilt+reinstalled on BOTH emulators this session). Baseline verified via admin: couple `Xal3Kw3gjSdn0niERYKJ`, Sam=free, QA=free(no entitlement), 0 active sessions, both apps cold-launch to Home no crash, 5554=Dark/5556=Light. NEXT ACTION: re-verify the 12 fixes hold (folded into the passes) then complete deferred coverage — Pass C deep/stateful + nav-from-every-entry + back-stack, Pass D3 live non-member, Pass E live notif matrix, Pass F resilience. Progress logged per-pass below + in ClaudeQACoverage.md.**
> _Round 2 result (carried): FIX PHASE COMPLETE — P1×2 (B-001 session rules, C-NAV-001 back-stack), P2×4 (A-001, B-002, C-CC-001, C-DS-001), P3×4 (A-003, B-003, E-002, F-OBS) all FIXED; D-001 (P1 rules) + E-001 (P2 routing) fixed earlier. All verified LIVE except E-002/F-OBS (code+build; live-trigger deferred). Rules deployed._
> Pass-B note: a finished game keeps its session active until a player exits the results (Back to Play); leaving both on results blocks the next game until "End their game". Exit cleanly between games.
> **Pass-B MINDSET (user, 2026-06-24): PLAY AS THE USER** — navigate only via the real in-app path a person would tap (no deep-links/admin pokes/shortcuts); expect what a user expects. When the natural path fails, **REPORT FIRST** (log issue + severity + the user action that failed & what was expected), **THEN** a minimal workaround to proceed — never silently engineer around breakage; a flow needing a workaround is broken and must be filed.
> R2-1 DONE: A-001 couple-shared re-verified live (Desire Sync/Memory Lane/Wheel enter when partner premium; free→paywall). **D-001 (P1) FIXED+DEPLOYED** (capsules/challenges rules; Memory Lane + Connection Challenges now load). Sam reverted to free (baseline).
> Round 1 complete (all 5 passes run report-only; P0P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix blockers inline; keep cycling fix→re-QA until flawless. Do NOT hand back when context fills — the harness auto-compacts and you continue from THIS run-state (re-read it + coverage after any summary). Commit before interruptible work; recover stuck sessions via the session-start ritual.**
> **STANDING AUTHORIZATION (user, 2026-06-24): may `firebase deploy --only firestore:rules` + has admin access (Firestore reads/writes/seeds + entitlement toggles) — run these without pausing. Only the macOS requirement for iOS (Parts 2/3) remains a hard stop.**
> Playbook: `ClaudeQAPlan.md`. Coverage matrix: `ClaudeQACoverage.md`. Report-only during passes (no fixes until the fix phase).
> Devices: emulator-5554 (QA=`Y05AKO`) + emulator-5556 (Sam=`imDjjO`), paired (coupleId `Xal3Kw3gjSdn0niERYKJ`). Build == HEAD `64f0a7e`.
_(Prior games/notifications QA from 2026-06-24 was completed + verified; superseded by this full-app effort.)_
---
## Severity summary (current — R1 fixed + R2 findings)
| Severity | Open | Fixed |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | 4 |
| P2 | 0 | 4 |
| P3 | 0 | 4 |
**ALL KNOWN ISSUES FIXED (P0P3).** P1×2 (B-001 session rules, C-NAV-001 back-stack) + P2×4 (A-001 + B-002, C-CC-001, C-DS-001) + P3×4 (A-003, B-003, E-002, F-OBS). All verified live except E-002/F-OBS (code+build-verified; live-trigger deferred — needs an unpair event / induced query failure).
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2).
**Round 2 (Pass B play-as-user restart) new/changed:** **B-001** escalated **P3→P1** (a finished game never closes its
session via any normal path — proven: both tapped "Back to Play", session stayed `active` 12s later — so every game
blocks the next, recoverable only via the destructive "End their game"; breaks the core loop); **B-002 (P2, new)** Home
"Play now / your partner is waiting to play" lands on the generic Play hub instead of resuming/indicating the waiting
game (confirmed with a live session too). Open P3: A-003 (badge), E-002 (informational notif routing), F-OBS
(load-fail handling). Deferred for "flawless": exhaustive deep/stateful screens (Pass C), full live notif matrix + D3.
---
## Round 3 re-QA log (2026-06-25, build `ce7fc2e`) — fix regression + deferred coverage
**Fixes re-verified LIVE this round:**
- **C-NAV-001 ✅** — cold start (logged in) → Home → system Back → focus = `NexusLauncherActivity` (app exits). No onboarding resurfacing.
- **C-CC-001 ✅** — Play hub → Connection Challenges (active Gratitude Week) → single header (`Back` desc count = 1), no duplicate title.
- **Back-stack ✅** — clean cold-start hierarchy: deep screen (challenge) → Back → Play hub → Back → Home → Back → launcher. No double-back, no dead-ends. (Earlier "double-back" suspicion was warm nav-state restoration of the last Play-tab destination, not a real defect — does not reproduce from cold start.)
- **A-001 ✅ (couple-shared)** — QA set premium, Sam left free. Sam (5556, partner-premium) Play hub → Desire Sync opens to "How long?" setup (no paywall) + Memory Lane opens (sealed capsule shown). QA (5554, self-premium) likewise unlocked.
- **A-003 ✅** — Play hub shows **0 "Premium" badges** on both 5554 (self-prem) and 5556 (partner-prem couple-shared).
- **D-001 ✅** — Sam opened Memory Lane → capsule list renders, **0 PERMISSION_DENIED** in logcat (capsules rule holds; no hung heart → F-OBS path healthy).
**Desire Sync two-device playthrough (premium ON; QA started, Sam joined):**
- **B-001 ✅** — both answered all 5 → admin read shows **active=0** (session auto-flipped to completed, no "End their game" needed). Core loop intact.
- **B-003 ✅** — reveal counts fully coherent: "3 shared desires / 2 answers stayed private" + tiles "You: Private / partner: Private" + caption "3 shared, 2 kept private". No contradicting "5 private".
- **C-DS-001 ✅** — 5554 (dark) reveal "You both said yes to" list renders crisp **white high-contrast** text (old dim muted-pink gone); 5556 (light) black-on-light. Both readable.
- Gameplay PASS — privacy logic correct (QA T,Y,Y,Y,T vs Sam T,Y,N,Y,F → exactly the 3 mutual-affirmative shown, 2 mismatches hidden), reveals match on both, no crash. Sam (free) joined QA's session = couple-shared join works.
**This or That two-device playthrough (immediately after Desire Sync — 2nd consecutive game):**
- **B-002 ✅** — QA started This or That → Sam's Home showed "Game waiting / Your partner is waiting to play" → Sam tapped **"Play now"** → landed directly in This or That **1/5** (the exact waiting game), NOT the generic hub.
- **B-001 ✅ (2nd consecutive game)** — both answered 5/5 → admin **active=0** again. Proven a couple can play two games back-to-back with no dangling/blocking session. Core loop solid.
- Gameplay PASS — both picked A on all 5 → Sam results "5/5 in sync — Two peas in a pod, matched on 5 of 5" with correct per-Q breakdown; consistent on both; **0 FATAL** in logcat.
_Still to verify this round: neither-premium locked state (toggle off), remaining games (How Well, Wheel, Date Match, Connection Challenges day-cycle, Memory Lane create), edges (re-open completed / leave mid-game), Pass C deep screens, E live matrix, D3 non-member, Pass F._
## Pass A — Couple-shared premium ✅ pass complete
**Target:** if either partner is premium, all premium features unlock for both.
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | **FIXED** — added `showPremiumBadge` param to `DesireSyncCard`/`MemoryLaneCard`, gated the badge behind it, pass `!hasPremium` from the Play hub. **Verified LIVE:** with couple premium, Play hub shows 0 "Premium" badges on those cards (both cards present, no lock). |
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| B-001 | Games / sessions | couples/{id}/sessions | **P1** (was P3→P2→P1) | **A finished game NEVER closes its session — there is no normal-user path to complete it — so every game leaves a dangling `status=active` session that blocks ALL other games.** Definitively proven on the R2-B2 restart: played This or That fully through on BOTH devices → both reached the results screen → **BOTH tapped the intended "Back to Play" button** → both navigated back to the Play hub, **but the session stayed `active`** (re-checked at +0s and +12s; no cloud-function cleanup; `completedAt` never set). So neither "Back to Play" nor leaving to Home completes a finished session — the ONLY thing that does is the **destructive "End their game"** (which the next game offers as "Sam is playing a … game", misleading copy since nobody is actually playing). Net: a couple **cannot cleanly play two games in a row** — after every game, the next one is blocked until one partner kills the (already-finished) session. This breaks the core game loop for every session → **P1**. **ROOT CAUSE (found in fix phase): a Firestore RULES bug, not app code.** The sessions `allow update` rule required `affectedKeys().hasOnly(['status','completedAt'])`, but the async-game completion path (`markUserComplete`) always writes **`completedByUsers`** (each player records themselves; the session flips to `completed` only once both are in). So every "I reached results" write was **denied** (the failure is swallowed by `onFailure`), `completedByUsers` never reached 2, and the session stayed `active` forever. `abandonSession` ("End their game") only diffs `status`/`completedAt`, so it passed the rule — exactly why that was the only thing that worked. | Play This or That to results on both → session stayed `active`; next game blocked. | **FIXED + DEPLOYED** — sessions `allow update` now permits `['status','completedAt','completedByUsers']`, lets any couple member record completion progress, keeps `startedByUserId` immutable + status monotonic (active→completed, never revert). **Re-verified LIVE:** played This or That fully on both → session auto-flipped to `status=completed`, `completedByUsers=[both]`, **0 active sessions** (no Back-to-Play/End-their-game needed); then **opened How Well immediately → its setup screen, NOT "Waiting for Sam"**. Core loop restored. |
| B-002 | Home → Play nav (play-as-user) | HomeScreen "Your partner is waiting to play" card → "Play now" | **P2** | The Home card explicitly promises resuming the specific waiting game — "**Your partner is waiting to play. A game is ready for the two of you. Jump back in and keep the ritual going.**" → **"Play now"** — but tapping it just lands on the **generic Play hub** (the game list). It does NOT open/resume the waiting game, and the Play hub shows **no indication of which game is waiting** nor any "resume" affordance. A user told to "jump back in" cannot tell what to tap or how to rejoin. (Also: BOTH partners' Home cards say "**your** partner is waiting to play" for the same session, so each thinks the other is mid-game.) **Fix:** "Play now" should deep-link into the active session (its play/results screen), or the Play hub should surface a "Resume — How Well" entry; the Home copy should reflect whose turn it actually is. | Cold start → Home → tap "Play now" → lands on Play hub, no waiting-game indicator. | **FIXED** — Home now resolves the active session's `gameType` → its resume route (`gameRouteFor`: wheel→SpinWheelRandom, this_or_that/how_well/desire_sync→themselves), stored as `HomeUiState.waitingGameRoute` and carried on `HomeAction.gameRoute`; `HomeActionTarget.Game` navigates there (fallback Play hub). Each game screen auto-joins the couple's active session on open, so "Play now" resumes the exact waiting game. **Verified LIVE:** Sam started This or That → QA Home "Play now" → landed directly in This or That (1/5), not the hub. |
| B-003 | Desire Sync results (copy/clarity) | DesireSyncScreen results | **P3** | The results stats are internally **inconsistent/confusing**. Header: "**3 shared desires — 2 answers stayed private.**" Per-person row: "**You 5 private / Sam 5 private**". Progress bar caption: "**3 shared, 2 kept private.**" So the same screen says both "2 kept private" (total) AND "5 private" (each person) — a user can't tell whether 3 are shared or all 5 stayed private. (Mechanically "5 private" likely means "all 5 of each person's raw answers stay private, 3 happened to overlap", but that framing isn't clear and contradicts the "2 kept private" line.) **Fix:** make the three counters consistent (e.g., drop or relabel the per-person "5 private", or clarify "your individual answers are always private"). | Play Desire Sync to results → read the three differing private/shared counts. | **FIXED** — the per-person privacy tiles no longer show the contradicting "$total private"; they now read just "Private" (your individual answers always stay private), and the caption keeps the real "$matches shared, N kept private" breakdown. **Verified LIVE:** reveal now shows "You: Private / Sam: Private" + "5 shared, 0 kept private" — no contradiction. |
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
**R2-B2 Desire Sync playthrough (couple-shared premium):** QA (free) entered with NO paywall (A-001 holds live). Both played full 5 Yes/No; QA T,T,T,T,F + Sam T,T,F,T,F → results show **exactly 3 shared desires** (the mutual-yes Q1/Q2/Q4) with Q3 (mismatch) and Q5 (both no) correctly **hidden** — reveal/privacy logic CORRECT; results match on both devices; no crash. Findings: B-003 (P3 copy), C-DS-001 (P2 dark contrast on revealed list).
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
**Verified clean (both themes, readable, no clipping):** Home, Today, Play, Messages inbox, Settings. `closerBackgroundBrush()` is theme-aware (adapts). No FATAL on these.
| ID | Area | Screen | Severity | Description | Status |
|---|---|---|---|---|---|
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
| C-CC-001 | Nav / layout — duplicate header + double back | ConnectionChallengesScreen (series list) | **P2** | The screen shows **TWO stacked "Connection Challenges" titles, each with its own back arrow** — a nav-scaffold app bar (title "Connection Challenges" + back) AND an in-content header ("Connection Challenges / Pick a series to build a habit together." + a second back arrow right below it). Verified it's a **redundant duplicate header, not a double-pushed route**: tapping the inner back arrow pops straight to the Play hub (same as the app-bar back). No dead-end, but two identical titles + two back buttons is confusing ("which back do I press?") and looks broken — exactly the "double back" case to flag. **Fix:** drop the in-content TopAppBar/back (let the nav scaffold own the title+back), or remove the scaffold bar for this route. | Play hub → Connection Challenges → two "Connection Challenges" headers + two back arrows stacked at top. | **FIXED** — removed `CONNECTION_CHALLENGES` from `shellBackRoutes` (the screen renders its own header for both the pick + active views, unlike This or That/How Well/Desire Sync which rely on the shell). **Verified LIVE:** the screen now shows a single header + single back arrow (1 "Back", no duplicate title). |
| C-DS-001 | Theming / readability (dark) | DesireSyncScreen results — "You both said yes to" list | **P2** | In **dark mode**, the revealed shared-desire list items render as **dim, low-contrast muted-pink text on a dark pink-tinted card** — legible but well below the crisp high-contrast black text the same items show in **light mode** (verified side-by-side: QA dark vs Sam light). Given the user's "text must be readable" bar — and that this is the intimate payoff content the user most wants to read — the dark-mode contrast is too low. (May be intentional "muted" styling; if so it needs a dark-mode-specific brighter token.) | 5554=Dark: play Desire Sync to results → the 3 shared-desire rows are dim/hard to read vs the same rows on 5556=Light. | **FIXED**`DesireMatchCard` text color was a hardcoded dark plum `Color(0xFF3D1F2E)`; changed to theme-aware `MaterialTheme.colorScheme.onSurface` (dark text on light, light text on dark). **Verified LIVE:** played Desire Sync to reveal in dark mode → shared-desire rows now render crisp high-contrast white text. |
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
| C-NAV-001 | Nav / back-stack — onboarding+auth not popped after login | MainActivity AppNavigation (start/auth/onboarding graph) | **P1** | **The auth + onboarding destinations are never popped from the nav back stack after login, so pressing system Back from Home walks BACKWARD into onboarding → the welcome/login screen instead of exiting the app.** Confirmed with a CLEAN reproduction (no scripted pollution): cold start → land on **Home** (authenticated, "Connected with Sam") → press system **Back once** → lands on the **"Answer honestly" onboarding carousel** (still inside `closer.app/app.closer.MainActivity`, so it's in-app nav, not a separate task). Tapping the carousel's **Skip** then reaches **"Closer — Create account / I already have an account"** (the pre-auth welcome) — i.e., a logged-in user pressing Back appears to be logged out. Not data loss (cold start returns to Home; Firebase auth persists), but it's a core, every-user nav defect and very alarming UX. **Fix:** on successful auth/onboarding completion, navigate to Home with `popUpTo(<auth/onboarding graph or start route>) { inclusive = true }` (and `launchSingleTop`) so Home is the back-stack root and Back from Home exits the app. | Cold start (logged in) → Home → press system Back → onboarding carousel appears instead of the app closing. | **FIXED** — in `AppNavigation.navigateRoute`, navigating to HOME *from* an entry route (ONBOARDING/CREATE_PROFILE/PAIR_PROMPT/LOGIN/SIGN_UP/FORGOT_PASSWORD) now does `navigate(HOME){ popUpTo(0){inclusive=true}; launchSingleTop }`, wiping the entire pre-app flow so HOME is the back-stack root. Normal tab-switch semantics (`selectTab`) untouched. **Re-verified LIVE:** cold start (logged in) → Home → system Back → focused activity is the **launcher** (`NexusLauncherActivity`), app exits cleanly — onboarding no longer resurfaces. |
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
**Pass C requirement (added):** **navigation from every entry point** (each screen reached from all its links — e.g. conversation from inbox/Discuss/notification; game from Play/notification; paywall from each gate) + **back-stack / "double-back"** (system back AND in-app back return to the right place from each entry; no dead-ends, no exit-app surprise, **no screen needing two backs**/duplicate stack entries; deep-link/notification entries land with a sane back stack). Owed in Round 2. Wrong/double back or dead-end = P2 (P1 if it traps the user).
## Pass D — Security & Encryption ✅ clean (no P0/P1 found)
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`; **Memory Lane capsules `title` + `content` = `enc:v1:`** (live-verified R2-B2: admin read the just-created capsule → both fields ciphertext, `status:sealed`, `unlockAt` set, only metadata plaintext). Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
- **D2 rules:** no catch-all `match /{document=**}`, no blanket `if true`; **`hasPremium` server-only** (client create/update blocked, rules L172/174); entitlements `write:false`; conversations/messages/typing/reactions + entitlement partner-read scoped to members.
- **D4 key exchange:** pairing uses a **wrapped couple key** (`wrappedCoupleKey` + `kdfSalt`/`kdfParams` + `encryptedRecoveryPhrase`); invite code is the KDF seed, never stored raw; strict E2EE (invites without a wrapped key rejected) — confirmed in `acceptInviteCallable`.
- **D5 App Check/secrets:** App Check enforced (`SecurityModule`, `PlayIntegrityChecker`, `FirebaseInitializer`); both service-account JSONs gitignored **and untracked**; `allowBackup=false`.
- **D6 leak vectors:** analytics events carry only metadata (no message/answer content); `allowBackup=false`.
_Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh 3rd account (rule logic verified member-scoped); a fresh Storage-bytes spot-check of chat media._
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| D-001 | Rules — missing subcollection rules | **P1** | `couples/{id}/capsules` and `couples/{id}/challenges` had **no `match` block** → default-deny → **Memory Lane hung on its loading heart** and **Connection Challenges** couldn't load (live `PERMISSION_DENIED` confirmed). Two premium features broken. | Sam premium, QA opens Memory Lane → stuck loading heart; logcat `Listen for Query(.../capsules) failed: PERMISSION_DENIED`. | **FIXED + DEPLOYED** — added member-read + ciphertext-enforcing `capsules` rule (title/content/promptUsed must be `enc:v1:`) and a `challenges` rule (catalog-referenced, progress-only). Re-verified live: Memory Lane shows empty state, Connection Challenges shows the series list, **0 permission errors**. |
| F-OBS | Resilience (UI) | **P3** | MemoryLaneScreen (and likely others) **hangs on the loading indicator forever** when a Firestore query fails, instead of showing an error/empty state. Masked the D-001 root cause. Add load-failure handling. | Was visible before D-001 fix (stuck heart). | **FIXED** (code) — ROOT CAUSE: `FirestoreCapsuleDataSource.observeCapsules` **swallowed** snapshot-listener errors (`if (err != null …) return@`), so on PERMISSION_DENIED the callbackFlow never emitted or closed → the ViewModel's `collect` suspended forever → stuck loading heart. Now it `close(err)`s the flow, so the ViewModel's existing `runCatching.onFailure``MemoryLanePhase.ERROR` (with a Retry) runs. Build green; live-verify needs an induced query failure (deferred). (Other snapshot listeners with the same swallow pattern are a follow-up sweep.) |
| (outcomes) | Rules | — | The Round-1 `outcomes` list `PERMISSION_DENIED` is **by-design** — the rule restricts reads to specific dayKeys (`day_0/30/60/90`); a bare list query is correctly denied. Not a bug. | — | Closed (by-design) |
## Pass E — Notifications
- **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6)
- **Routing:** centralized in `PartnerNotificationType` (`fromRemoteType` → `routeFor`); chat opens the exact conversation, reveal→answerReveal(questionId), games→Play, capsule→Memory Lane, etc.
- **Foundations** (prior round, code present): FCM token registration on sign-in, POST_NOTIFICATIONS, channels.
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | **FIXED** (code; live-tap deferred) — added `PARTNER_UNPAIRED` type, mapped `partner_left` + `partner_deleted_account` → it → routes to **HOME** (where the now-unpaired user gets the "Invite partner" CTA, matching the push body "Tap to create a new invite"). **Investigation corrected two false positives:** `invite_created` is a server-side **audit-log** entry (`read:true`, "not read by clients" — never a push), and `spki` is a **crypto key-format string** in the RevenueCat webhook (`crypto.createPublicKey({type:'spki'})`), not a notification type at all — neither needs client routing (documented in `fromRemoteType`). Build green; live tap-verify deferred (needs an actual unpair event). |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._