Closer/ClaudeReport.md

46 KiB
Raw Blame History

Claude QA Report — Full-App QA (living report)

RUN-STATE: Round 7 (multi-angle DEEP DIVE) — 2026-06-25, client HEAD f47c8e2, functions deployed. Plan updated with a "Multi-angle attack mandate" + live raw-API D3. Attacked security/data/concurrency from multiple angles (admin ground-truth read, raw Firestore REST as member+non-member, killed/cold state, malformed intents, simultaneous-start race). Security cornerstone = FULLY CLEAN (deep): D1 at-rest — messages/previews + all 4 game-answer collections (ToT/HowWell/DesireSync/Wheel, both users) + capsules + date-swipe actions all enc:v1:; couple key phrase-wrapped (argon2id), recovery phrase server-blind + encryptedRecoveryPhrase wiped on acceptance, plaintext inviteCode not exploitable (invite readable only by inviter; no code-encrypted secret persisted). D3 raw-API — non-member denied ALL reads/writes (403); real premium path users/{uid}/entitlements/premium write denied (403, server-only) → no self-grant; cross-couple denied. Robustness — malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal values) → 0 crash; killed-state cold-start chat deep-link → conversation loads. NEW FINDING: F-RACE-001 (P1) — simultaneous game start creates two divergent active sessions (TOCTOU). SEVERITY BOARD: P1 = 1 open (F-RACE-001), P0/P2/P3 = 0 open. Baseline restored (duplicate sessions ended, 0 active, couple intact). Two hardening notes → Future.md (App Check not enforced on Firestore; user-doc update rule allows arbitrary non-hasPremium fields).**

F-RACE-001 (P1, NEW — concurrency): When BOTH partners start the same game within ~the same second, the couple ends up with 2 active sessions with different question sets (proven live: QA "Which should end the date?" vs Sam "Which feels more romantic?", two session docs bw8Q3X45… + yNOzMTOCsGlZPbnv2yBN). Root cause: GameSessionManager. startGameWithCouple (usecase/GameSessionManager.kt:84106) does a non-transactional check-then-creategetActiveSessionForCouple then saveSession (auto-id); two concurrent calls both read null → both create. The existing partner_active_session guard only covers the non-simultaneous case. Impact: the two partners play separate games and never get a shared reveal (core loop silently defeated); two active sessions can also lock/confuse the "one active game" rule. No crash, no data loss; recoverable via "End their game"/admin. Repro: stage both at This or That mood-select, fire "create" on both in parallel. Suggested fix: make session creation atomic — a Firestore transaction with a per-couple active-session sentinel (e.g. couples/{cid} field activeSessionId or a sessions/_active doc): read sentinel → if an active session exists, take the join/partner_active_session path; else create the session doc (client-generated id) + set the sentinel in the same transaction. Clear the sentinel on finish. Needs a firestore.rules update (member-only sentinel write) + a rules deploy + re-verify all 7 games. (This is an architectural change to the core game flow — flagged for a focused fix-phase implementation.)

RUN-STATE: Round 6 (branding + Future.md regression QA) COMPLETE — 2026-06-25. Client HEAD f47c8e2 on both emulators (build == HEAD, reinstalled). Scope: regression-verify the new surfaces from the branding drop (95cad84: white-keyhole launcher/notification icons, animated app-icon-chip loader + fill, cold-launch splash, pairing hero) and the Future.md backlog clear (f47c8e2: inclusive gender options, turn-aware Home copy, push rate-limit budget split, results-push suppression via new ActiveGameSessionMonitor, paywall retry/offline/hide-Continue, auth privacy rotator). 0 new issues — SEVERITY BOARD STILL 0 open P0P3. LIVE-VERIFIED: animated loader (chip+fill, both themes), splash→handoff (white-keyhole icon, no white flash), launcher icon (round mask), This or That + How Well open with no crash (confirms #4's new VM injection is sound), paywall purchase screen shows friendly "Couldn't load plans" + Try again with Continue hidden (no dead button) + online→generic message (#5), onboarding carousel illustration. Unit tests green (NotificationRateLimiter rewritten + PartnerNotificationManagerTest repaired). No FATAL on either device all session. CODE/UNIT-VERIFIED (live deferred, low-risk over proven patterns + fragile multi-text-field/2-device paths): #1 gender step (EditProfile + onboarding sex step — same option list as the shipping Female/Male), #8 rotator on SignUp/Forgot (reuses the Login-proven BrandMessageRotator), #2 "Your turn to play." (static string in the proven GAME_WAITING path), #3 weekly-cap exemption (unit-tested; only triggers at ≥100/wk), #4 results-suppression timing (mechanism + VM wiring verified; simultaneous-finish timing is non-deterministic to drive). Baseline restored: 5554 signed out during the sign-up pass, re-signed-in QA (Y05AKO2IlTPMa0JQW1BiNIM0uzK2) via admin custom token; couple Xal3Kw3gjSdn0niERYKJ intact, Sam paired.**

RUN-STATE: Round 5 (functions deploy + expanded re-QA) COMPLETE — 2026-06-25. Client HEAD 765916a on both emulators; Cloud Functions DEPLOYED (firebase deploy --only functions → "Deploy complete", all 30+ fns updated). Fixed + verified LIVE: E-OBS (all 12 FCM senders now set android.notification.channelId → backgrounded chat push lands on partner_activity, NOT fcm_fallback), E-003 results-ready (server sends game_session_id; finished-game deep-link → per-session "This or That Results" screen, not hub/setup). Expanded coverage per user request: VARIED GAMEPLAY (Standard/Deep + 0-match "Total opposites" result path), exhaustive NAV FUZZING (rapid triple-tap opens setup once via launchSingleTop; back-stack clean; no dead-ends/double-back), and NEW PASS G account-creation/fake-account — ALL SECURE: sign-up+validation (weak-pw → friendly error), fresh-account isolation (zero couple data), duplicate-email → auth/email-already-exists, invite single-use+24h-expiry + bogus code → "Invite not found", recovery phrase client-generated. SEVERITY BOARD: 0 open at ALL levels (P0P3). Baseline restored: couple intact, both free, 0 active sessions, throwaway test account deleted, Sam re-paired. Round 4 (carried): E-003 game-push + B-004 WaitingForPartner "Join the game" + A-OBS paywall copy all FIXED + verified live. 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 — after Round 4 fix phase + re-QA)

Severity Open Fixed (verified live)
P0 0 0
P1 0 4
P2 0 6
P3 0 6

Round 5 result: 0 open issues at every severity (P0P3). E-OBS (the last open P3) is now FIXED + DEPLOYED + verified live along with the E-003 results-ready follow-up. New Pass G (account creation + fake-account abuse) ran clean — no security findings. All prior fixes hold. The app meets the "flawless" bar (0 open P0P2, Passes D + E clean) — and beyond it (0 open P3 too).

Round 5 dispositions (functions deployed):

  • E-OBS (P3) — FIXED + DEPLOYED (21b078a senders, server live): all 12 FCM senders set android.notification.channelId (game→game_activity, chat/partner→partner_activity, reminders→reminders; gameRetention varies by type). Verified live: backgrounded QA→Sam chat push now shows channel=partner_activity (was fcm_fallback_notification_channel).
  • E-003 results-ready — FIXED + DEPLOYED (aaab768 client + server game_session_id): onGameSessionUpdate now sends game_session_id; client gameResultsRouteFor routes game_results_ready to the per-session replay (thisOrThatReplay/howWellReplay/desireSyncReplay/wheelComplete). Verified live: finished-game deep-link → "This or That Results" (0/10, that session), not hub/setup. (Live finished-push didn't post on Sam — fired while foreground + 20/day rate limiter after a full QA day; routing verified via the exact deep-link intent.)
  • Pass G — CLEAN (no findings): sign-up + validation (weak-pw → "Password must be at least 8 characters."), fresh-account isolation (unpaired, zero couple data), duplicate-email → auth/email-already-exists (rejected), invite code single-use + 24h expiry, bogus code → "Invite not found." (rejected, friendly), recovery phrase client-generated. Sign-out → onboarding → debug-token restore all work.
  • Varied gameplay (Pass B style): This or That Standard(10)/Deep + all-mismatch → "0/10 in sync — Total opposites" result path renders correctly (distinct from the 5/5 Quick/Light path). Nav fuzzing: rapid triple-tap opens the game setup once (launchSingleTop, no stacked duplicates); back-stack game→hub→Home→launcher clean; no dead-ends/double-back.

R3/R4 issue dispositions:

  • E-003 (P2) — FIXED 23c9923: game pushes (partner_started_game/partner_completed_part) now route via gameRouteForType(payload.gameType) into the specific game (auto-joins the active session), not the Play hub. Server already sends game_type; client parses it in AppMessagingService + MainActivity. game_results_ready stays on the hub pending a server change to also send game_session_id (documented). Verified live: tapped start-game push → This or That 1/5 (joined).
  • B-004 (P2) — FIXED da7fc74: WaitingForPartnerScreen now resolves the active session's game route and offers a primary "Join the game" action (every game is async/joinable), so the partner is never stuck. Verified live via deterministic repro: QA started How Well → Sam opened This or That → WaitingForPartner → "Join the game" → How Well guess intro.
  • A-OBS (P3) — FIXED 6f6f76a: paywall ErrorState no longer renders the raw billing/RC SDK message; shows friendly "We couldn't load subscription options right now…". Verified live (raw "credentials issue" gone).
  • E-OBS (P3) — OPEN, deferred: backgrounded pushes use fcm_fallback_notification_channel, bypassing code-defined channels. Fix is server-side (set android.notification.channel_id on every FCM send across functions, or send data-only + build client-side) + a functions deploy (user-gated). Cannot verify without deploying.
  • C-OBS (RESOLVED, not a bug): Settings "Art preview/Paired home (debug)" entries ARE BuildConfig.DEBUG-gated (SettingsScreen.kt:469) — won't ship in release.

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.

How Well Do You Know Me two-device playthrough (QA subject, Sam guesser):

  • Gameplay PASS (×2) — QA answered 5 about self (incl. two 1-5 scale Qs); Sam guessed via Play hub → reveal "5 of 5 — Perfect read / You guessed 5 of 5 about QA" with all-correct breakdown on both; scoring accurate; no crash.
  • B-001 (3rd game type) — session auto-completed (active=0) once both submitted.
  • B-002 (clean case) — with the subject (QA) DONE first, Sam's Home "Play now" → How Well guess INTRO ("I'm ready") correctly (route gameRouteFor(how_well)=HOW_WELL → HowWellScreen.joinSession → INTRO).
  • B-004 (NEW, P2, intermittent) — guesser can get stuck on the generic "Waiting for Partner" screen for How Well. Observed once: during a rapid This-or-That→How-Well transition (Sam had just finished This or That; QA then started How Well and was still mid-answer), Sam tapped Home "Play now" and landed on the generic WaitingForPartnerScreen ("Waiting for QA / QA is playing a How Well game"), which only exits when the session ends (its VM navigates away only on session==null) — it never routes the guesser into the guess flow. So the guesser is trapped there (recoverable only via "Back to Games" / "End their game"; re-entering How Well via the Play hub then works). NOT reproduced in the clean subject-done case (Play now → INTRO worked). Likely a stale waitingGameRoute/transition race sending the guesser to a non-How-Well game screen (which sees an active how_well session of a "different type" → WaitingForPartner) or directly to WAITING_FOR_PARTNER. Repro is timing-dependent — needs a deterministic trigger; if it proves deterministic for "guesser taps Play now while subject is mid-answer", escalate to P1 (traps the user). Report-only (logged, not fixed mid-pass).

Spin the Wheel two-device playthrough: QA spun → "Emotional Intimacy" (10 Qs) → Start session → Sam joined QA's active wheel session (1/10). Both answered all 10 → reveal "Complete / Here's how you each answered / Emotional Intimacy" with per-Q You/Sam breakdown on both; session auto-closed (active=0) → B-001 holds (4th game type: desire_sync/this_or_that/how_well/wheel all auto-complete); 0 FATAL. (Some rows "Skipped" = free-text prompts the automated driver doesn't type; not an app bug.) D-001/Memory Lane (re-confirmed R3): Sam (partner-prem) opened Memory Lane → existing sealed capsule "Opens in 29 days" renders, no hung heart, 0 PERMISSION_DENIED. Connection Challenges (re-confirmed R3): active "Gratitude Week / Day 2 of 7" loads with single header (C-CC-001), back returns to Play hub cleanly.

Date Match (R3): opens (single header, "Swiping with Sam"), deck advances through cards (Sunrise hike → Overnight camping…), premium date ideas accessible under couple premium, 3 existing matches badge, no FATAL. (Full mutual-match + live push verified R2-B2.) Pass A neither→locked — premium toggled OFF, both free → Play hub re-shows Premium badges (Memory Lane 🔒, Past Games 🔒; count back to 2, A-003 gating confirmed BOTH directions) → tapping Desire Sync opens the paywall ("Go deeper together / Unlock everything Closer has built for couples", What's-included list, Continue/Restore) — gate correctly blocks free users (does NOT enter the game). Pass A fully re-verified: neither→paywall, partner→couple-shared unlock, self→unlock, A-003 badges both directions.

  • A-OBS (P3/observe, likely env-only): the paywall's plan list fails with "Couldn't load plans — There was a credentials issue. Check the underlying error for more details." + disabled Continue. Expected in this emulator (no RevenueCat/Play-billing sandbox), so the gate itself is fine; but that raw developer-ish error copy is user-facing — in prod, a plan-load failure should show a friendlier message. Flag for copy review (not a gate bug).

Pass B (R3) — all 7 game areas covered: Desire Sync , This or That , How Well (+B-004 logged), Spin the Wheel , Date Match , Connection Challenges (loads/single-header/active Day 2), Memory Lane (loads/sealed capsule). B-001 confirmed across 4 async game types (auto-complete, no stuck session). B-002 works (clean case). All fixes (B-001/B-002/B-003/C-DS-001) hold.

Pass C (R3) — deep-screen visual sweep (5554=Dark primary; several seen in Light on 5556 during A/B): Verified render cleanly, readable, no FATAL, no new dark-mode contrast issues — Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+ Subscription "One subscription for both partners — no double billing", + Appearance Theme radios), Today/daily-question (incl. answer detail "Save privately / Discuss"), Messages inbox (avatars/timestamps), Conversation (image + voice + text msgs, ❤️ reaction, "Seen", input bar). E2EE UI check: 0 enc:v1 ciphertext leaked into the conversation UI (messages decrypt for the user). C-DS-001 dark-contrast fix holds.

  • C-OBS (P3/observe): Settings shows "Art preview (debug)" + "Paired home (debug)" entries — debug-only menu items (expected in this debug build; confirm they're BuildConfig.DEBUG-gated so they don't ship in release).
  • Deferred (nav-drift made per-screen capture slow; standard list/detail screens, lower risk): Question Packs detail, Bucket List, Past Games, Wheel History, Answer Reveal (sealed), Date Builder/Plan Date, and a fresh-account pass on auth/onboarding/pairing. No issues seen on the ~14 screen-types reached; the deferred set is standard Compose list/detail using the same theme tokens already verified.

Pass D (R3) — re-audit clean, no P0/P1:

  • D2 rules (deployed) re-audited ✓ — no catch-all match /{document=**}, no blanket if true; sessions update (B-001 fix present): only ['status','completedAt','completedByUsers'], startedByUserId immutable, status monotonic active→completed; hasPremium server-only (client write+diff blocked L172/174); entitlements owner+partner read (couple-shared) / write server-only; capsules (D-001) member-read + ciphertext-enforced (isCiphertext title+content) + authorId-bound + key allowlist + coupleEncryptionEnabled; challenges (D-001) member-read + progress-only writes.
  • D1 at-rest ✓ — live admin read: chat text=enc:v1:, lastMessagePreview=enc:v1: (media-only msg has no text field = no plaintext); how_well answers + Memory Lane capsules = enc:v1: (Pass B). No plaintext content leak. UI check: 0 enc:v1: rendered to the user (Pass C conversation).
  • D4 (wrapped couple key / KDF), D5 (App Check, gitignored SA JSONs, allowBackup=false), D6 (analytics metadata-only) unchanged since Round 1 — code identical, still hold.
  • D3 live non-member negative test: still deferred — needs a 3rd fresh account not in the couple (only 2 emulators, both members; signing one out risks the App Check debug token + couple state). Rule logic is statically member-scoped (isCouplesMember gate on every couple subcollection) — denial holds by construction.

Pass E (R3) — live notification tests (both FCM tokens valid, len=142):

  • chat_message FULL CHAIN — Sam backgrounded; QA sent a message → Sam received push title "QA sent a message" / body "Tap to read and reply." (content-free ✓, actual text NOT in payload → D6 holds) → tapped → opened the exact conversation with the new message loaded (deep-link ✓, background→cold path).
  • partner_started_game — Sam backgrounded; QA started This or That → Sam received "QA is playing / QA has started a game. Tap to join!" (delivery ✓, content-free ✓). BUT tap → landed on the generic Play hub, NOT the game.
  • E-003 (NEW, P2) — game notifications deep-link to the generic Play hub, not the specific game/results. Code: PARTNER_STARTED_GAME/GAME_RESULTS_READY/PARTNER_COMPLETED_PART all routeFor → AppRoute.PLAY (PartnerNotificationManager L270-272). The body says "Tap to join!" but the user lands on the hub and must find+tap the game card themselves (tapping it does then join the session, per B-002). Same gap B-002 fixed for the Home "Play now" card — never extended to notifications. Plan's Pass E wants "the specific item, not just the right tab" (strictly P1; rated P2 since it lands on the right tab and is recoverable). Fix: route game pushes through the active-session→game-route resolver (like HomeViewModel.gameRouteFor) so the deep-link joins the game. Report-only.
  • E-OBS (NEW, P3) — backgrounded pushes use fcm_fallback_notification_channel, not their code-defined channels. The delivered chat + game pushes both landed on fcm_fallback_notification_channel even though the code assigns CHANNEL_GAMES/chat channels (PartnerNotificationManager L185). Means the server sends FCM "notification" (not data-only) messages, so the system auto-displays them on the fallback channel when backgrounded — bypassing the app's channel importance/sound and the per-category toggle (users can't mute just "Games"). Fix: send data-only FCM + build the notification client-side with the right channel, or set android.notification.channel_id in the FCM payload. Report-only.
  • Foundations — both users registered FCM tokens; routing centralized in PartnerNotificationType; E-001 (daily_question/challenge_day_ready) + E-002 (partner_left→HOME) fixes present in code. date_match push live-verified R2-B2.
  • Full 17×{fg/bg/killed} matrix not exhaustively run; chat_message + partner_started_game live-verified this round (deliver+content-free; chat deep-link ✓, game deep-link → E-003). Remaining types: routing code-verified.

Still to verify this round: edges (re-open completed / leave mid-game), 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.
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.

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.
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).
(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.

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 (fromRemoteTyperouteFor); 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.