Closer/ClaudeReport.md

112 KiB
Raw Blame History

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

Verdict (2026-06-27): R15 = gap-closing round on Passes L/M/N/P + smoke — found & FIXED M-001 (P2 quiet hours). Targeted the previously-uncovered gaps (the new "flawless" bar needs L + P clean + M take-effect). Found M-001 (P2): "Quiet hours — 10 PM8 AM, no notifications" did not suppress partner pushes when the recipient app was backgrounded/killed (the main case) — quiet hours was local-only (never synced server-side) and the OS shows the FCM notification block directly without running app code. Fixed + verified live: client now mirrors the window+timezone to users/{uid}; the 4 partner-action senders (onMessageWritten/onAnswerWritten/onAnswerRevealed/onGameSessionUpdate) suppress server-side via a fail-open recipientInQuietHours(); rules allowlist extended for the new fields. Live: QH ON → function logs …is in quiet hours — suppressing, 0 delivery; QH OFF → notified partner, delivery resumes; per-type chat toggle still suppresses (server-enforced). Then drove Pass N (user "FIX"): N-001 (P1) — Bucket List was entirely non-functional (coupleId never wired → all CRUD silently no-op) → FIXED + verified live (add enc:v1:/complete/delete/render). N-002 (P2) — "Plan a Date"/Date Builder "Create Plan" was a no-op (wrote to an unread prefs collection; dateIdeaId/coupleId never wired) → FIXED + verified live (re-pointed to create a PLANNED DatePlan → Home shows "Date coming up"). Outcomes/Your Progress code-correct. Clean passes: L (chat decrypt both dirs, attribution, Seen receipt, ❤️ reaction, ordering, day-sep, inbox no enc: leak, at-rest enc:v1:); P (UI copy warm/inclusive, debug rows BuildConfig.DEBUG-gated, friendly error fallbacks; question bank 6103 Qs — 0 empty, 0 dupes, 0 placeholders, complete answer configs, on-guide tone); daily-Q + reveal gate render; smoke 6/6 GREEN both emulators. 2 P3 brand-asset backlogs still open. 0 FATAL.

Verdict (2026-06-27): R14 = full fresh AJ ClaudeQAPlan run — 0 open P0P2, 0 new functional findings. A pure-QA confirmation round (no code changes) on the R13 build. (A follow-up 2026-06-27 brand-standards audit then opened 2 P3 brand-asset backlogs — every image needs a dark variant; every icon must be custom — see the Issues section + ClaudeBrandingReview.md.) The 5 R13 fixes (C-DARK-UI-001/002/003, C-ART-EDGE-002, J-OBS) held through R14's sweep → pruned; the Premium-unlock modal held + re-verified. Live results: Pass A — premium ENFORCEMENT audited (all 6 gated features have a real gate, not just a badge; A-201 class closed) + free→Paywall confirmed for Date Match / Desire Sync / Question Packs + couple-shared unlock (Sam→QA) with modal + subscription_entitlement_changed push delivered live. Pass B — Desire Sync / How Well / Spin-the-Wheel full 2-device (mixed answer types incl free-text + multi-select + skipped-answer reveal), first-finisher partner_completed_part nudge confirmed live, Memory Lane create+seal (premium), Connection Challenges resume, Date Match deck (ToT carried from R13). Pass C — broad both-theme sweep + decoupled-theme-art mandate (system-light+app-Dark → dark UI + correctly-themed feathered art). Pass D — cornerstone LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest enc:v1:). Pass E — all triggers fired live with content-free copy to the right partner. Pass F — offline cache render + process-death recovery, both 0 FATAL. Pass I — jank 5.25%. Pass J — J-OBS 48dp holds. 0 FATAL across the whole run, both emulators.

Verdict (2026-06-27): R13 = fixed the entire open backlog + full fresh AJ — FLAWLESS (0 open P0P3). Took over and fixed all 3 open Codex dark-mode findings (C-DARK-UI-001 P2 This-or-That redesign; C-DARK-UI-002 P3 check-in label/value; C-DARK-UI-003 P3 bottom-inset clipping) plus the 2 carried P3s (C-ART-EDGE-002 direct-call hero feathering; J-OBS 48dp touch targets), and confirmed A-201 (P1) live → pruned. Also shipped the branding Premium-unlock modal (illustration_premium_unlock, one-time, shown to BOTH partners on couple-shared activation). All verified live on both emulators (5554 dark / 5556 light), 0 FATAL. Full fresh AJ run clean: Pass D security cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, chat at-rest enc:v1:); A premium gates → Paywall (Date Match + Desire Sync); B ToT full both themes + Wheel launch; I jank 6.43% (perf-safe); J 48dp confirmed. Diff is UI-only (no rules/functions/crypto change) → E/F/G carried. All app changes in the working tree — user commits.

Verdict addendum (2026-06-27): ad hoc DARK-MODE UI/brand review on dedicated Codex emulator COMPLETE. Built + installed the current debug APK on my own CloserCodexQA emulator (emulator-5558), forced system dark mode, created a fresh real paired couple through the app invite flow, and swept profile/onboarding, unpaired invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, and Today. Button text is generally readable across profile/Home/Settings/Notifications/Messages/Paywall, but the sweep found 1 open P2: This-or-That active gameplay has low-contrast dark option text and an off-brand diagonal/circle backdrop crossing the prompt. Also found 2 open P3s: first-launch check-in modal label/value collision and recurring bottom-inset clipping on scroll content near nav/gesture areas. Logs checked after navigation/game entry: 0 app FATAL/ANR/force-finish; only uiautomator/system noise plus a non-crashing BillingClient unbind warning.

Verdict (2026-06-27): R11 confirmation round COMPLETE — FLAWLESS (0 open P0P2). Fixed the last open P2 (C-DARKART-001 — dark art now follows the in-app theme) + the open P3 (C-ART-EDGE-001 — art feathers into the surface), both in the shared BrandIllustration/EmptyState helpers, verified live on both decoupled theme directions (system-light+app-Dark → dark aubergine art; system-dark+app-Light → light pastel art), 0 FATAL. Re-confirmed all 5 R10 P2 fixes hold (C-HOME-001 single card · C-NAV-002 wheel-back popUpTo present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible live · C-SEC-001 recovery row active for accepter live) → pruned. Entrypoint launch-integrity smoke green (splash-crash class clean on the fresh APK). Only remaining: 2 freshly-fixed art items pending 1 confirm + J-OBS (P3 touch targets). Art fixes in working tree — user commits.

📖 Architecture reference: see docs/Engineering_Reference_Manual.md. Most fixed-and-pruned IDs above are documented in its Known landmines and recent fixes section — read before re-touching the affected area.

Verdict (2026-06-26): R10 FULL ClaudeQAPlan run COMPLETE (AJ + fix phase). 0 open P0P2; 1 P3 (J-OBS). Found 5 P2 (Home dup card, wheel back-stack, duplicate app bar, dark paywall contrast, recovery-phrase wrong store) — ALL fixed + verified live + regression-clean. E-GAME-002 confirmed live + pruned. Security cornerstone clean (D1D7). [Pruned in R11.]

This report shows current state only. Fixed issues live here for one confirmation round, then they're pruned to the archived-ID line below (full detail stays in git history). See Report hygiene in ClaudeQAPlan.md.

Run-state (current)

  • R24-d (2026-06-30) — two restore-flow UX fixes per user report ("there needs to be a back button on help your partner restore. also tapping recovery phrase does nothing"). Both LIVE-verified on QA/Sam; 244 unit tests green (no regressions). Why: the manual "Help my partner restore" consent screen (R24-c) opened with no way back except gesture, and on an emulator/device with no enrolled lock, tapping "Recovery phrase" launched BiometricPrompt which silently errored (only onAuthenticationSucceeded was overridden) → felt like a dead tap. What (uncommitted): (1) Back buttonRestoreScreens.RestoreScaffold gained an optional onBack param rendering a CloserGlyphs.Back IconButton; RestoreRequestScreen/RestoreConsentScreen accept onBack, wired in AppNavigation to navigateBackOrHome (popBackStack() else Home). (2) Lock-less revealSecurityScreen.launchBiometricForPhrase now checks BiometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) != BIOMETRIC_SUCCESS (or null activity) and, when there's no lock to prompt, reveals the phrase directly (viewModel.revealRecoveryPhrase()) instead of firing a prompt that no-ops. When a lock is enrolled, behaviour is unchanged (still gated). LIVE-verified: QA (no PIN) — tapping Recovery phrase now immediately shows the dialog with the 12-word phrase ("lion fair card like foot good full fame disk flat"); Sam — Settings → Security → "Help my partner restore" opens the consent screen with a back arrow, and the empty-state card renders ("There's no restore request waiting right now…" — no active QA request), and tapping back returns to Security. Verification: :app:assembleDebug OK + :app:testDebugUnitTest 244 green (UI-only change, no test delta). Uncommitted: ui/pairing/RestoreScreens.kt, ui/settings/SecurityScreen.kt, core/navigation/AppNavigation.kt.
  • R24-b (2026-06-30) — hardened partner-assisted restore per user's "check the account matches the email" concern: recipient identity + active confirm gate on consent, two request-lifecycle bug fixes, and owner security alerts. 243 unit tests green; consent identity + confirm gate + re-request fix + full loop LIVE-verified on QA/Sam. Why: when the partner approves, they hand over the couple key — the consent screen never showed who was receiving it, and the recipient's own account got no alert. Investigating surfaced two real robustness bugs too. What (uncommitted): (1) Consent identity + confirm gate (RestoreViewModel/RestoreScreens) — Sam now sees the recipient's email (plaintext anchor) + display name (decrypted locally via the couple key; the server can't, so identity is necessarily client-side), plus an explicit "I reached them directly and this is their account" checkbox; Approve is gated on code.length==6 && confirmed. Added distinct no-request / expired empty states (the screen previously showed the form unconditionally on a stale deep-link). (2) Two lifecycle bugs found + fixed (RestoreManager): Bug AcreateRestoreRequest's set() over an existing request is a rule-denied key-changing update → a retry silently PERMISSION_DENIEDs; fixed by delete-then-create. Bug BexpiresAt was never enforced; fulfillRestore now rejects an expired request before wrapping the key. (3) Owner security alerts (onRestoreRequested + new onRestoreFulfilled) — a "was this you?" alert to the recipient's OWN devices at request time and on key transfer (the real anti-takeover signal for a phished password when the owner still has a device); quiet-hours bypassed (security), deduped via couples/{id}.lastRestoreSelfAlertAt (60s) against create-loops, each notify branch independently try/caught; client RESTORE_SELF_ALERT type wired (isEnabled true, routeFor AppRoute.SECURITY, fromRemoteType). Honest framing (SECURITY.md): the code is the crypto root of trust; identity+confirm stop accidental approval + add social-engineering friction (they do NOT stop takeover — the couple email matches); the owner self-alert is the takeover catch; the strongest control (email-verification challenge) needs mail infra → Future.md. Verification: :app:assembleDebug + 243 unit tests (+10: RestoreViewModelTest ×6 — identity/fallback/expired/no-request/approve-gating; RestoreManagerTest ×4 — delete-then-create order, expiry + wrong-code rejection, no-expiry-legacy allowed; functions onRestoreRequested.test.ts ×6 — dedupe window + READY-transition guard as extracted pure helpers) + functions build green + wiring-scan 🔴0 dead setters / 0 dead notif settings. LIVE on QA(5554)/Sam(5556), fresh APK: Sam's consent shows QA's real email qa_1782321603516@closertest.com + name "QA" + the confirm checkbox using the name; Approve stays disabled with code-only, enables only after checking the box; re-request after a prior request created a fresh code (370690) with NO PERMISSION_DENIED (Bug A fix); approve → QA restored key (couple_crypto recreated) + 34 messages with no phrase and returned Home (R24 regression through the new gate). FOUND + FIXED — misleading Recovery-phrase empty-state on a partner-restored device (user-asked "ensure Recovery Phrase displays as it should"). After partner-assist restore, storeTransferredKeyset intentionally transfers the key but not the phrase (the phrase is one-way from the couple key), so QA's Settings → Security correctly greyed the "Recovery phrase" row — but the copy read "…will appear here once your couple is set up on this device," which is false (QA is fully set up, chat working). Fixed SecurityScreen/SecurityViewModel to detect key-present-but-no-phrase (encryptionManager.aeadFor(coupleId) != null with recoveryPhrase == null) and show the accurate partner-restore copy ("You set this device up with your partner's help, so the recovery phrase isn't saved here. Ask your partner to open Settings → Security and read you theirs — either partner's phrase can restore your history."). LIVE-verified both devices: Sam (original device) — Recovery phrase row enabled → PIN-gated reveal shows the correct 12-word phrase ("lion fair card like foot good full fame disk flat") with the right explanatory copy; QA (partner-restored) — greyed row now shows the accurate partner-restore message (not the "not set up" copy). Navigation (answered): recipient reaches restore via the NEEDS_RECOVERY "Unlock your history" screen → "No phrase? Ask your partner to restore this device" → RestoreRequestScreen; the helper reaches consent only via the restore_requested notification (OS push + in-app inbox entry once the function is deployed) → RESTORE_CONSENT — no manual Settings entry exists (candidate follow-on). BUILT (R24-c, user-asked "we will build that entry backup") — partner-assist now also restores the recovery phrase + a manual helper entry. After confirming a partner-restored device was fully restored (key + content) but deliberately phrase-less, added: (1) phrase transfer — the keybox ECIES payload became an envelope ckx:v1:{keyset, phrase?} (CoupleKeyTransfer.wrapCoupleKey(...recoveryPhrase)unwrapCoupleKey returns TransferredKey(keyset, phrase?)CoupleEncryptionManager.storeTransferredKeyset(coupleId, handle, phrase?)); RestoreManager.fulfillRestore includes the sender's phrase, completeRestore stores it. Backward-compatible (a prefix-less legacy keybox → key-only). The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — server stays blind; both partners are meant to hold the shared phrase, so this restores parity, not exposure. (2) Manual "Help my partner restore" row in Settings → Security (gated on holding the couple key) → RESTORE_CONSENT, so the approver can reach consent even if the notification never arrives. LIVE-verified QA/Sam: full re-run (QA wiped → requested code 462926 → Sam approved via the new manual Settings entry, not a notification) restored key + 34 msgs; QA's Settings → Security Recovery phrase row now enabled, and PIN-gated reveal shows the identical phrase as Sam ("lion fair card like foot good full fame disk flat") — the greyed/partner-restore state is gone; couple_crypto_secure.xml grew 1646→1872 B (phrase now stored). Unit: CoupleKeyTransferTest +1 (phrase round-trip) + return-type update → 244 total green. GATED (user deploys): onRestoreRequested+onRestoreFulfilled (self-alert pushes) + still-pending storage.rules (snapshot compaction) + R23 date functions. All uncommitted (user commits): ui/pairing/{RestoreViewModel,RestoreScreens}.kt, ui/settings/SecurityScreen.kt, crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt, data/backup/RestoreManager.kt, notifications/PartnerNotificationManager.kt, functions/src/backup/onRestoreRequested.ts + index.ts, {RestoreViewModel,RestoreManager,CoupleKeyTransfer}Test.kt, onRestoreRequested.test.ts, + docs (SECURITY.md, Engineering_Reference_Manual R24-b/c, Future.md, ClaudeiOSPlan, this entry).
  • R24 (2026-06-30) — built E2EE conversation backup + full partner-assisted restore (foundation for Option B). Code-complete, 233 unit tests green, deploy-gated for live verify. What (uncommitted): devices keep a couple-key-encrypted backup of all conversations so a new/wiped device restores history, and a partner can fully restore for the other (key + content) with no recovery phrase. Phase A — schema (FirestoreCollections backup/restore consts) + BackupCodec (JSON→enc:v1: envelope, checksum, reactions) + FirestoreBackupDataSource (manifest generation-CAS transactions, chunk append, snapshot finalize w/ delete-after-commit, restore_requests) + firestore.rules (backup manifest/chunks + restore_requests, new isPublicKey helper) + storage.rules (users/{uid}/backups/ — uploader-scoped, reuses existing onUserDelete cleanup). Phase BBackupManager (incremental appendSince + full-state compact that captures deletes/reactions; resolved-timestamp-only; throttled/single-flighted) + conversation backup-reads on FirestoreConversationDataSource; opportunistic trigger from HomeViewModel.loadHome. Phase C — separate ConversationCacheDatabase (Room; kept OFF the asset-backed AppDatabase to protect its schema hash) + BackupRestoreManager (download snapshot+chunks → decrypt → checksum-validate → upsert dedup-by-id). Phase D (headline)CoupleKeyTransfer (ECIES-wrap the couple keyset to a recovering device's fresh pubkey; context "{coupleId}|restore|{sender}|{recipient}|{nonce}"; 6-digit OOB code = SHA-256(pubkey‖nonce)) + RestoreManager (A request→complete, B fulfil-after-code) + RestoreViewModel/RestoreRequestScreen/RestoreConsentScreen + RecoveryScreen "Ask your partner to restore" entry + nav. Phase EonRestoreRequested Cloud Function (notifies partner; high-signal, not toggle-gated; audit-log no key material) + RESTORE_REQUESTED notif type wired (fromRemoteType/routeFor/isEnabled). Verification: :app:assembleDebug + 233 unit tests (9 new: CoupleKeyTransferTest proves the couple key round-trips only to the right device+context, wrong key/nonce fail, OOB code binds to pubkey+nonce for swap-detection; BackupCodecTest round-trip/checksum/forward-compat/cursor) + functions tsc green + wiring-scan 🔴0. Security posture: server holds only enc:v1: ciphertext + keybox:v1: blobs (AEAD tamper-evident); the OOB-code entry gate defeats a server/MITM pubkey swap + remote account-takeover; unpair revokes (rules require active couple); threat model + residual risks (no forward secrecy, rollback-freshness, client-enforced verification) documented in SECURITY.md. Docs: SECURITY.md (E2EE list + 2 threat rows + roadmap), Eng Ref Manual (R24-BACKUP landmine w/ wire formats + hazards), ClaudeQAPlan (Pass D backup/restore + rules-negative + Pass E restore_requested), ClaudeiOSPlan (byte-compat parity item), Future.md (Option B + FS + freshness + WorkManager/backfill + Settings indicator/opt-out). LIVE-VERIFIED (2026-06-30) on QA(5554)/Sam(5556), fresh APK, -gpu swiftshader_indirect: simulated device loss on QA by deleting only the secure prefs (couple_crypto_secure.xml = couple key + user_key_secure.xml = ECIES key) via run-as (NO pm clear — App Check token preserved) + backgrounding (KEYCODE_HOME) + am kill to drop the in-memory key → cold-launch QA lands on NEEDS_RECOVERY. Full partner-assist (no phrase): QA "Ask your partner to restore this device" → RestoreRequestScreen "Start restore" → published a fresh pub:v1: + created restore_requests/{QA} (rules allowed, no PERMISSION_DENIED) → showed OOB code 940687. Sam driven to RESTORE_CONSENT (deep-link, function-independent) → typed 940687 → "Approve restore" → fulfillRestore verified code == SHA-256(pubkey‖nonce), wrapped the couple keyset to QA's fresh pubkey (keybox:v1:), set status READY. QA auto-completed with NO recovery phrasecouple_crypto_secure.xml recreated at 20:00 via unwrapCoupleKey+storeTransferredKeyset; QA navigated past NEEDS_RECOVERY to Home ("Connected with Sam"). Content restored: 34 messages across all 4 conversations (main:28 + 3 q_daily_fun_* discuss threads) upserted into QA's separate conversation_cache.db (confirmed via on-device sqlite3 count) — sourced from the Firestore enc:v1: chunks (server-blind). Restored key decrypts live content: QA Messages inbox + main thread render the full history in plaintext ("hi"/"hey", R18 test msgs, "gu", today's pings) — not 🔒 placeholders. completeRestore deleted the request post-unwrap (no lingering keybox). DEPLOY GAP FOUND (not a code bug): storage.rules was NOT deployed (only firestore.rules was — which is why every Firestore-side op above worked). Both devices' backupNowcompact snapshot upload 403s (GetDownloadUrlTask "Permission denied") because the deployed storage.rules lacks the users/{uid}/backups/ block → snapshot compaction can't run. Backup + restore still work fully via the Firestore chunks (the 34-msg restore proves it); only the Storage snapshot/compaction (chunk-folding) is blocked. Fix = deploy firebase deploy --only storage (rules file is correct + structurally identical to the working chat_media block); after deploy, compaction folds chunks → snapshot automatically on the next backupNow. Verdict: R24 — headline full partner-assisted restore (key + 34 msgs, NO phrase, OOB-code-gated) VERIFIED LIVE end-to-end + server-blind; 0 FATAL. One outstanding DEPLOY-gate: user runs firebase deploy --only storage to enable snapshot compaction (+ still-pending onRestoreRequested for the partner push, and the R23 date functions). Board unchanged: 0 open P0/P1. All uncommitted (user commits): data/backup/*, data/remote/{FirestoreBackupDataSource,BackupCodec,FirebaseStorageDataSource,FirestoreConversationDataSource,FirestoreCollections}.kt, crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt, data/local/ConversationCache*, di/DatabaseModule.kt, domain/model/ConversationBackup.kt, ui/pairing/{RestoreViewModel,RestoreScreens,RecoveryScreen}.kt, ui/home/HomeViewModel.kt, notifications/PartnerNotificationManager.kt, core/navigation/{AppRoute,AppNavigation}.kt, functions/src/backup/onRestoreRequested.ts + index.ts, firestore.rules, storage.rules, app/build.gradle.kts, tests, + the docs above.
  • R23 (2026-06-30) — built Date Memories & Replay (the date-roadmap killer feature) end-to-end, then LIVE-verified the whole loop on QA↔Sam. 0 defects in the new feature, 0 FATAL. Work (uncommitted): the private→reveal loop applied to real dates — mark a mutual match "We did this"couples/{c}/date_history/{matchId} (PLAINTEXT coarse metadata, idempotent doc-id=matchId merge) → date_reflections/{dateId}/answers/{uid} (+ read-gated secure/payload) E2EE, mirroring the daily-question couple-key gated reveal exactly. New: DateMemory/DateReflection models, FirestoreDateMemoryDataSource/FirestoreDateReflectionDataSource, DateReflectionScreen+VM (EDIT→AWAITING→REVEALED), DateMemoriesScreen+VM (Replay timeline), DateMatchesScreen "We did this" + "Your date memories" entry, Phase C Home nudge (HomePriorityEngine.DATE_REFLECTION_PENDING value-action + HomeViewModel pending computation + HomeActionTarget.DateMemories + glyph_date_replay), 2 Cloud Functions (onDateReflectionWritten/onDateHistoryCreated), partner-sheet emoji→brand-glyph retrofit, firestore.rules (date_history+date_reflections, DEPLOYED by user), docs (SECURITY/iOS-parity/QA-plan Pass E+N). Cheap gates GREEN: :app:compileDebugKotlin + assembleDebug (128MB APK) + HomePriorityEngineTest 25/25 (2 new DATE_REFLECTION_PENDING cases) + functions tsc; wiring-scan 🔴0 dead setters / 0 dead notif settings for the date feature. LIVE on QA(5554)/Sam(5556), fresh APK, software-GL: mark-done → date_history synced to both timelines · QA reflects → AWAITING ("Saved privately 💜 — waiting for Sam"), privacy gate holds (QA can't see Sam pre-both) · Sam reflects → mutual reveal side-by-side, QA screen live-flipped with no refresh (observeReflected), bidirectional E2EE decrypt with correct You/partner labels · empty-state art (illustration_date_memories_empty) · timeline newest-first, per-row chips Reflect/View · Home nudge "Reflect on your date with Sam 💭" appears in BOTH the Also waiting (pending) and More ways to connect (secondary) surfaces with glyph_date_replay, routes to the timeline, clears after reflecting · mark-done idempotency (both partners tapped "We did this" on the same date → timeline shows it ONCE) · light + dark both date screens · cold-start no crash (nav restores back-stack) · partner-sheet retrofit renders all 5 actions as brand glyphs (no emoji-as-icons). FOUND + FIXED — R23-DQ-001 (P2) daily-question silent re-answer data loss: answering an already-answered daily Q logged Write failed at …/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED — the immutable-answer guard (secure allow update:false) correctly rejecting an overwrite. Root cause: the daily-Q screen + Home sourced "answered?" from local Room only, so on a fresh device / cleared DB (Room empty while Firestore still holds the answer) Home showed a stale "your turn" and the screen offered an editable re-answer — and a changed pick would be silently dropped (the secure overwrite is denied; reveal keeps the old content). NOT caused by the date work (git diff = only date_* rule additions; daily-Q rules/datasource untouched). Fix (uncommitted): new Room-first reconcileLocalAnswerFromFirestore (ui/questions/LocalAnswerMapping.kt) — if Room lacks the answer but Firestore has it, rebuild from the read-gated couple-key payload (owner can always read their own), map option-texts, and write it back to Room; persists only when the payload actually decrypts (a transient key-miss never poisons Room). Wired into DailyQuestionViewModel.loadDailyQuestion (awaited → screen shows submitted/reveal, never a lossy re-answer) and HomeViewModel.loadHome (non-blocking heal → no stale "your turn"). Room-first ⇒ the normal answered path is byte-identical. Covered by 5 new unit tests (ReconcileLocalAnswerTest: Room-hit short-circuit, heal+persist+option-text-map, no-prior-answer→null, assignment-rotation guard, undecryptable→answered-but-not-persisted). Full suite 224 green, APK builds. Live fresh-device repro is pm clear-gated (forbidden — App Check token) so verified by unit tests + the provably-equivalent withLocalAnswer render path. QuestionDetailViewModel (pack questions) audited — local-only writes, no gated-secure path → not vulnerable (no change). The date feature is also immune (its hasReflected reads Firestore, not Room). GATED (user-only): notification pushes (date_reflection_partner/date_reflection_ready/date_logged) need the 2 new functions deployed — code-complete + tsc-green, not yet live. Verdict: R23 — Date Memories & Replay shipped + LIVE-verified flawless across the full loop + Home nudge + idempotency + light/dark; 0 defects in the feature, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Pending: user deploys the 2 date functions → verify the 3 date pushes live. Uncommitted (user commits): the new date *.kt + models + datasources + screens, HomePriorityEngine.kt/HomeViewModel.kt/HomeScreen.kt, DateMatchesScreen.kt/DateMatchesViewModel.kt, AppRoute.kt/AppNavigation.kt, FirestoreCollections.kt, PartnerNotificationManager.kt/AppMessagingService.kt, functions/src/dates/* + index.ts, firestore.rules, glyph_date_replay.xml + illustration_date_memories_empty.png (+night), HomePriorityEngineTest.kt, SECURITY.md/ClaudeiOSPlan.md/ClaudeQAPlan.md/ClaudeReport.md.
  • R22 (2026-06-29) — SECURITY.md recs #6 + #7 implemented, then full QA pass. 0 new defects, 0 FATAL. Work (uncommitted): #6 recovery-phrase save-confirmation at pairing — the invite screen now makes the inviter re-type one random word of the phrase ("type word #N") before pairing feels done → "✓ Saved — you're all set." (CreateInviteScreen.kt). ✓ verified live on a fresh account (5558): renders, correct word → confirmed, index randomizes per entry (#5 then #8). #7 biometric app-lock re-arms on backgroundMainActivity lifecycle observer drops the unlocked session after the app is backgrounded past a 60s grace (BIOMETRIC_RELOCK_GRACE_MS), so a picked-up open phone re-prompts (not only cold-start); grace avoids re-locking on quick task-switches. Code-complete + compiles; live re-lock pending a physical device (emulators have no enrolled biometric/PIN). SECURITY.md/Future.md updated (#6/#7 → done). QA run: cheap gates ALL GREEN — build + 210 unit + 24 functions, theme-scan CRIT 0, painter-xml 0; baseline both free, 0 active sessions, 0 FATAL both. Smoke: 5556 6/6 (launcher + all 5 notif cold-starts open&stay); 5554 launcher PASS + 5 FCM-delivery BLOCKs (environmental "flaky emulator FCM, rerun" — 0 FAIL, no crashes; shared notif code path proven by 5556). A cornerstone live (free → Desire Sync → Paywall, warmed "Full answer history and growth" renders). D carries from R20 (no rules/crypto change); B/E + this session's copy/bubble/#6/reveal verified R21. INFRA finding (not app): the 2nd/3rd emulator crashed seconds after boot with eglMakeCurrent failed / Draw context is NULLhost GPU/EGL context exhaustion from running 3 emulators on the hardware GPU. Fixed by killing the spare (5558) + relaunching the QA/Sam pair with -gpu swiftshader_indirect (software GL) — stable since. Saved to memory (QA-ops). Verdict: R22 — #6 shipped+verified, #7 code-complete (needs-device), build stable + cornerstones hold, 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Uncommitted (user commits): MainActivity.kt, CreateInviteScreen.kt, SECURITY.md, Future.md, ClaudeReport.md, ClaudeQACoverage.md (+ the broader session work). N-TODAY-001 (P3, FIXED) — Today reveal state was confusing (user-reported): after answering + revealing, the Today tab still showed the editable answer form + a prominent "Save privately" (looked re-answerable) AND a card titled "Answer revealed" that showed only the user's own answer (the mutual reveal is actually behind the "View reveal" button). Fixed in LocalQuestionContent.kt (Today-only — sole caller DailyQuestionScreen): the answer form now hides once submitted, and the card is retitled "Your answer" with an accurate status line ("Saved privately — waiting for your partner" / "…ready to reveal together" / "Revealed together — open View reveal to compare"). Verified live (5554, revealed state): form gone, card reads "Your answer / Cozy / Revealed together — open View reveal to compare"; 0 FATAL.
  • R21 (2026-06-29) — brand-voice + UX polish round, then full ClaudeQAPlan re-run (user: "change the language… more Closer-aligned vs therapy/corporate", "ensure the daily question shows to reveal when answered", "run the full QA plan, get to screens different ways"). 0 new defects, 0 FATAL. Copy/UX work (uncommitted): (1) Brand-voice sweepprompt → question across ~26 user-facing strings (Play hub "10 questions", Wheel "Ten questions per spin", Question packs/category/composer/thread, Spin-the-Wheel, Answers, Memory Lane, Home, date ideas; counts/plurals handled; internal ids like onPickPrompt/capsulePrompts/promptCountLabel/conversationPrompts + data keys left); clinical/corporate → Closer voice — Home eyebrow "Tonight's prompt"→"Your daily question", status chips "Prompt ready"→"Question ready" + "Private sync"→"Just for two"; the check-in/Outcome feature rewarmed (survey "How satisfied are you with intimacy?"→"How close do you feel physically?", "How well do you communicate?"→"How easy is it to talk lately?", "Submit"→"Save", "Quick check-in"→"A little check-in"; "Your Progress"→"Growing together", "Baseline/30-day check-in"→"Where you started/30 days in", "Change since baseline"→"Since you started", "…start tracking how your relationship feels…"→"…see how things feel between you two…"); paywall/subscription "…and insights"→"…and growth"; reveal "shared reflection"→"shared moment"; follow-up "ask one deeper follow-up?"→"go one question deeper?". (2) Home partner bubble upgrade — modern Coil SubcomposeAsyncImage (crossfade + centered-initials loading/error fallback), brand gradient ring, surface-ringed unread badge, a11y contentDescription; verified live (Sam's real photo loads in the ring). Verification (R21 QA run): cheap gates ALL GREEN — build + 210 unit + 24 functions, theme-scan CRIT 0 (9 MAJOR/21 REVIEW), painter-xml 0, entrypoint_smoke 6/6 on BOTH emulators; baseline both free, 0 active sessions. Reveal-when-answered VERIFIED LIVE end-to-end (the user's ask): answered the daily Q on both (QA "Cozy" via Today tab, Sam "Silly" via Home) → both Homes surfaced "Reveal is ready / Reveal together" + "Reveal ready" chip → tapped → AnswerReveal "Both answers are in" → revealed both picks ("Different picks. Honestly, useful."). Multi-angle nav (reached screens via different entries): daily Q via Today-tab + Home, reveal via Home card→reveal screen, Settings→"Growing together" (warmed labels render: "No check-ins yet"/"Where you started"/"30 days in"), Play→Question Packs ("250 questions"). All warmed copy renders correctly; 0 FATAL across the whole session. Cornerstones: E re-verified live (smoke 6/6 both + partner_answered path); N (daily-Q + reveal) live-clean; A/B/D carry from R20 (no rules/crypto/games-logic change this session — diff is copy + Home-bubble UI only). Verdict: R21 — brand-voice + bubble polish shipped + verified live across 5 surfaces; reveal-when-answered confirmed; all cheap gates green; 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001 pre-ship) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Also landed earlier this session (uncommitted): recovery-UX "ask your partner" copy + change-phrase desync guard, SECURITY.md (threat model + hardening roadmap), first instrumented test FirstRunRenderSmokeTest (proven to catch O-ONBOARD-001 class). Uncommitted (user commits): ~29 *.kt (copy sweep + HomeScreen/HomeViewModel + OutcomeCheckInDialog + YourProgress + recovery + crypto visibility + androidTest) + SECURITY.md + docs/Engineering_Reference_Manual.md + ClaudeReport.md/ClaudeQACoverage.md/ClaudeQAPlan.md/Future.md.
  • R20 (2026-06-29) — fresh full ClaudeQAPlan run from the start (user: "run the full ClaudeQAPlan") — found + FIXED 2 real escaped bugs (NOT a clean confirmation round). Baseline: HEAD 62696a6 (R18b/R19 work committed; clean tree), both emulators paired + free (admin-confirmed), build reinstalled both. Cleared 1 stale ToT session by playing it through. Cheap gates ALL GREEN: unit 210 · functions 24 · theme-scan CRITICAL 0 (9 MAJOR/23 REVIEW = intentional brand gradients) · painter-xml-scan 0 · wiring-scan 🔴0 · entrypoint_smoke.sh 6/6 on BOTH emulators (0 blocked). Discovery ritual: no drift (14 notif types + all fn triggers match coverage). Cornerstones live-clean: A enforcement audit (every isPremium/PremiumBadge has a real CouplePremiumChecker gate — no badge-without-gate; A-201 class stays closed) + live both-free → Desire Sync → Paywall "Go deeper together" (graceful K-env "couldn't load plans", no crash). B full 2-device This-or-That (QA joined via Home card → answered 10 → first-finisher → Sam got live YOUR_TURN banner → joined via banner → completion → symmetric 5/10 "in sync" reveal both devices). D D1 at-rest enc:v1: (messages + lastMessagePreview + all 4 game answer-maps' per-uid values) · D2 rules static (Tier-2 self-constraint present, lines 361374) · D3 non-member couple/messages/capsules/desire_sync reads 403 · D5 self-grant entitlement 403. E cold-start smoke 6/6 both + live YOUR_TURN + persistent RESULTS banners + partner_completed_part first-finisher push delivered. 0 FATAL across the whole live session. TWO BUGS FOUND + FIXED + VERIFIED LIVE: (1) B-ABANDON-001 (P2) — Quit/abandon on ANY game silently failed PERMISSION_DENIED: abandonSession round-tripped through saveSession (a full doc.set()) which drops the server-only flags (startNotifiedAt/joinNotifiedAt/partFinishNotifiedAt); the session-update rule counts those removed keys in affectedKeys() → denied, so the session stayed active (stranded → blocks new games), failure swallowed by Log.d. Proven via logcat (Write failed at .../sessions/…: PERMISSION_DENIEDquit-abandon no-op). Fix: targeted update(status, completedAt) mirroring markUserComplete (affectedKeys == {status, completedAt} ⊆ allowlist) in QuestionSessionRepositoryImpl.abandonSession; routed the latent-twin dead method GameSessionManager.finishGame (0 callers) through it too. Verified live: Quit → no denial → active=0, then started a different game immediately (lockout resolved). (2) B-COPY-001 (P3) — Home "GAME_WAITING" hero hardcoded "Your partner already played their part — take your turn to reveal" but fires on uid !in completedByUsers only (for async games completedByUsers stays empty until BOTH finish), so it falsely claims the partner finished the instant a game is merely started. Fix: neutral, partner-named, always-accurate copy ("Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up.") — the accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN banner. Verified live both devices (starter + joiner). Build + 210 unit + 24 functions green after fixes. Remaining passes carry recent-round status (zero functional diff coming in; my fixes are HomeViewModel copy + session-completion writes only, no effect on C/F/G/H/I/J/L/M/N/P): C theme-scan CRIT 0; L chat at-rest enc:v1: (via D1); K money-path + O release + Doze = blocked→needs-device. Verdict: R20 — cornerstones A/B/D/E live-clean, all cheap gates green, 0 FATAL; found + fixed B-ABANDON-001 (P2) + B-COPY-001 (P3) live. Board: 0 open P0/P1; 1 open P2 (O-AGE-001 pre-ship, user-blocked) + 1 P2 fixed-pending-confirm (B-ABANDON-001); 1 open P3 (BRAND-DARK-COVERAGE, user-blocked) + 1 P3 fixed-pending-confirm (B-COPY-001). Uncommitted (user commits): QuestionSessionRepositoryImpl.kt, GameSessionManager.kt, HomeViewModel.kt, ClaudeReport.md, ClaudeQACoverage.md, docs/Engineering_Reference_Manual.md. R20 follow-up (user: "make it so" on the instrumented smoke): added the project's first instrumented UI testapp/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt, an on-device Compose render smoke of the first-run crash composables (CtaSlide + AuthLogoMark, light+dark — the O-ONBOARD-001 painterResource sites). Infra: testInstrumentationRunner + ui-test-junit4 in build.gradle.kts; exposed CtaSlide as internal; un-blocked the androidTest source set (the stale CanonicalVectorCaptureInstrumentTest couldn't compile against private RecoveryKeyManager.deriveKey@VisibleForTesting internal). Verified on emulator-5558 (API 34): 4/4 pass; PROVEN to catch the class — reintroducing the <bitmap> foreground failed the test with the exact IllegalArgumentException: Only VectorDrawables… at loadVectorResource, then reverted → green. 210 unit + 24 functions still green. Wired into the QA-plan cheap gates (./gradlew :app:connectedDebugAndroidTest when an emulator is attached) + Future.md item marked started. Added files: build.gradle.kts, RecoveryKeyManager.kt, OnboardingScreen.kt, app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt, ClaudeQAPlan.md, Future.md.
  • R19 (2026-06-28) — fresh full ClaudeQAPlan run from the start (user-directed, post-implementation). Baseline reset clean: both emulators paired, both free (admin-confirmed premium=false), 0 active sessions (cleared the one stale ToT by completing it 10/10). Cheap gates ALL GREEN: ./gradlew testDebugUnitTest 210, cd functions && npm test 24, scripts/theme-scan.sh CRITICAL 0 (11 MAJOR/25 REVIEW = intentional brand-purple/white in the wheel + banner + bubble colored-surface contexts), scripts/painter-xml-scan.sh 0, qa/entrypoint_smoke.sh 6/6 on both emulators. Cornerstones live: A premium gate — both-free → Desire Sync → Paywall "Go deeper together" (graceful "couldn't load plans" K-env limit, no crash). B This-or-That full 2-device → completed 10/10 "in sync" reveal (+ this round's finish-gate / submit-retry / Quit-abandon). D security cornerstone (raw-API): non-member couple/messages/capsules/desire_sync reads + self-grant entitlement all DENIED 403; messages at-rest enc:v1:; session/answer docs carry no plaintext. E notifications: cold-start smoke 6/6 both + partner_joined_game live end-to-end (deployed) + standardized durable banner. 0 FATAL. Remaining passes carry their recent-round status (per ClaudeQACoverage.md, code-stable — this round's diff is additive client + the deploy-gated rule): C theme-scan CRIT 0 + this-session dark spot-checks; F offline-cache (carried) + new Wheel submit-retry (unit-tested); L chat at-rest enc:v1: confirmed; G/H/I/J/N/P carried; K money-path + O release + Doze/battery = blocked→needs-device (pre-ship). Verdict: R19 — cornerstones (A/B/D/E) live-clean, all cheap gates green, 0 FATAL; board 0 open P0/P1, 1 P2 (O-AGE-001 pre-ship), 1 P3 (BRAND-DARK-COVERAGE) — both user-blocked. Tier-2 rules DEPLOYED by user + VERIFIED LIVE (member-token raw-API): own-uid add to joinedByUsers ALLOWED 200 (legit path intact); foreign-uid add to joinedByUsers+completedByUsers DENIED 403; array removal DENIED 403 — own-uid-only self-constraint holds without breaking markUserJoined/markUserComplete. (functions + rules all deployed; no remaining deploy gates.)
  • R18b (2026-06-28) — polish + error-handling/security/consolidation hardening (user: "look for gaps, error handling, security, what else can we improve, can we consolidate"). Bundled with the modern-feel polish. Error handling: E1 (P2 bug) FIXEDWheelSessionViewModel.submitAndFinish used to swallow a submit failure and navigate to the reveal anyway (silent data loss + stuck session); now it surfaces a retryable error (submitFailed/error + a SubmitErrorCard "Retry", no false reveal), matching ToT/DesireSync/HowWell. E3 — the silently-swallowed join-writes now .onFailure { Log.w } in all 4 games. Consolidation (targeted): deduped the 3 copies of the save-error string into ui/games/GameCopy.SAVE_ANSWERS_ERROR (used by all four); the per-game answer/reveal logic is intentionally NOT unified (premature abstraction). Modern feel: GamePromptBanner — soft haptic on arrival (reuses CelebrationOverlay LocalHapticFeedback), spring overshoot entrance, live green presence dot on the avatar for JOINED, whole-card tap-to-act + swipe-up dismiss, liveRegion + contentDescription a11y, and GamePromptController now won't let a transient (started/joined) clobber a persistent (your-turn/results) banner; chat bubble got the same arrival haptic for parity; warmer brand-voice copy (banner styleFor = client source of truth, cross-commented with the function). Predictive back (2026): android:enableOnBackInvokedCallback="true" (safe — 0 onBackPressed/BackHandler in the app). Tier 3: the Wheel active screen got a "Quit game" escape hatch (abandon()abandonSession) so leaving mid-wheel no longer strands a session (mirrors ToT). Verified: build + 210 Android unit (+1 new Wheel submit-retry test) + 24 functions green; live (5554): JOINED banner shows warm copy + green presence dot + real avatar; Pass-E cold-start smoke 6/6 on BOTH emulators (notification path regression-clean); 0 FATAL. Tier 2 security — DEPLOYED + VERIFIED LIVE: firestore.rules now constrains completedByUsers/joinedByUsers so a member can only add their own uid (no spoofing the partner's join/completion, no removals; get(...,[]) tolerates old docs). Member-token raw-API test: own-uid add 200, foreign-uid add (both arrays) 403, removal 403; markUserJoined/markUserComplete still pass. Uncommitted (user commits): WheelSessionViewModel.kt, WheelSessionScreen.kt, WheelSessionViewModelTest.kt, GamePromptBanner.kt, GamePromptController.kt, MessageBubbleOverlay.kt, ThisOrThatScreen.kt, DesireSyncScreen.kt, HowWellScreen.kt, ui/games/GameCopy.kt, AndroidManifest.xml, firestore.rules, functions/src/games/onGameSessionUpdate.ts (sender_name), ClaudeReport.md, ClaudeQACoverage.md. NEXT: reset + full ClaudeQAPlan A→P fresh run (R19).
  • R18b (2026-06-28) — FEATURE: "partner joined your game" push + standardized in-app game banner (user: "notification that your paired partner joined the game … w/ their icon … make that theme the standard"). Decision (discussed): "Standardize, keep durable." New partner_joined_game: the non-starter opening an active session writes their uid to joinedByUsers (new field; client markUserJoined mirrors markUserComplete; firestore.rules session allowlist += joinedByUsers, server-only joinNotifiedAt deliberately excluded); onGameSessionUpdate adds a branch that claims a one-time joinNotifiedAt and notifies the starter " joined your game" with the joiner's avatar. Wired the join-write into all 4 games' non-starter paths (ToT/DesireSync/HowWell joinSession + Wheel load() resume), best-effort + off the critical path (guarded to never fire for the starter). Standardized banner: generalized GamePromptController/GamePromptBanner with a kind (STARTED/JOINED/YOUR_TURN/RESULTS) — per-kind copy/action/avatar; STARTED/JOINED transient (~9s), YOUR_TURN/RESULTS persistent (stay until tapped/dismissed). AppMessagingService now routes all 4 game types to the banner in the foreground (suppressing the foreground OS duplicate; background OS unchanged — already shows the avatar large-icon); AppNavigation routes the banner action by kind (RESULTS→per-session results, else→the game). Added sender_name to the game push data so the banner names the partner. Build + 209 Android unit + 24 functions tests green (added a PartnerNotificationTypeTest case for the new type's mapping + routing). Live (5554, foreground, real avatar+name): STARTED "Sam started a game"+Join (avatar loaded), JOINED "Sam joined your game"+View, YOUR_TURN "Sam finished their part / Your turn"+Play, RESULTS "Sam finished / See your results"+View — RESULTS still showing at 15s (persistent) while STARTED auto-dismissed by 12s (transient); 0 QA-SMOKE entries in the shade (no foreground OS dupe); 0 FATAL. Pass-E regression smoke 6/6 on BOTH emulators (shared cold-start path clean). ⚠ DEPLOY REQUIRED (user): the partner_joined_game push only fires once functions/ + firestore.rules are deployed to closer-app-22014 (I can't deploy; prior prod writes were classifier-denied) — until then the joinedByUsers client write is denied by the live rules (best-effort, swallowed, no crash). The banner-standardization for already-deployed types (started/your-turn/results) works immediately. Uncommitted (user commits): QuestionSession.kt, QuestionSessionRepository.kt(+Impl), GameSessionManager.kt, ThisOrThatScreen.kt, DesireSyncScreen.kt, HowWellScreen.kt, WheelSessionViewModel.kt, GamePromptController.kt, GamePromptBanner.kt, AppMessagingService.kt, PartnerNotificationManager.kt, AppNavigation.kt, PartnerNotificationTypeTest.kt, functions/src/games/onGameSessionUpdate.ts, firestore.rules, Future.md, ClaudeReport.md.
  • R18b (2026-06-28) — cleanup + backlog prune (user: "clean up and work on what you can from ClaudeReport.md and Future.md"). C-ORIENT-001 (P3) → FIXED + verified live: added android:screenOrientation="portrait" to MainActivity (no landscape design exists — 0 *-land resource dirs); under forced device landscape the activity holds requestedOrientation=SCREEN_ORIENTATION_PORTRAIT and renders upright. Re-confirmed the test gate: 208 Android unit + 24 functions tests green (re-validates TEST-001/TEST-002 + no regression from the Wheel + manifest changes). Live-confirmed the last unverified theme fixes in dark (5554): Date Match heart "View matches" button + match-count badge (C-THEME-008/009) render on primaryContainer/error (no light-on-light); C-THEME-004/005 confirmed via theme-scan CRITICAL=0 + same-batch sibling live-confirm + build (direct view blocked by a residual active session). Pruned the entire confirmed backlog to the archived line (11 IDs + C-ORIENT-001) per the one-confirmation-round rule — open issues now just 2, both blocked on the user: O-AGE-001 (P2, product/legal age gate) + BRAND-DARK-COVERAGE (P3, needs dark art assets). Triaged the rest of Future.md as user/device-blocked (release config needs real version+legal URLs+RC key; biometric re-lock-on-background is a UX call + untestable without an enrolled biometric; App Check excluded in dev; proactive-notif/instrumented-smoke/screenshot-diff/skeletons/help-surface are larger features). Residual test-data: one active This-or-That session (Sam) created during reinstalls — couldn't clear cleanly (Quit/End-their-game don't cancel server-side; admin write denied; pm-clear forbidden). Uncommitted (user commits): AndroidManifest.xml, ClaudeReport.md.
  • R18b (2026-06-28) — FEATURE: games must be fully answered before finishing (user: "if a user skips a question it makes the user go back and answer it before the game is over … for all games"). Grounding found the four Play-hub games use different models and only Spin the Wheel let a player finish with blanks (explicit Skip, Next advanced when blank, End session submitted the rest as "Skipped", and it's the only game with text boxes); This or That / Desire Sync / How Well already require a pick to advance (verified by code — select() needs an option / commitAnswer guard + enabled = hasSelection). Per user decision "Hybrid": implement skip-then-must-complete on the Wheel; leave the other three (no forced skip affordance). Wheel change (WheelSessionViewModel.kt + WheelSessionScreen.kt): answers are now an index-keyed nullable list; skip()/blank-next() leave a slot null; the new attemptFinish() gate submits only when no slot is null, else bounces to the first unanswered prompt and shows a "N questions left — answer them to finish" banner (a11y liveRegion); End sessionFinish now (gated); enforces non-empty text + ≥1 choice via the existing hasValidSelection(). Category-picker copy updated. Verified: build + 205→ unit tests green incl. 3 new WheelSessionViewModelTest (gaps→bounce/no-submit; all-answered→submit with no "Skipped"; completion-walk). Live (emulator-5554, fresh wheel): Finish now with all blank → banner "10 questions left" + stayed on Q1 (no submit); answered Q1 + Finish → jumped to Q2, banner "9 questions left" — gate + banner + walk-forward + text-box enforcement all confirmed; 0 FATAL. Full end-to-end (both emulators): played a complete Spin the Wheel on QA and Sam (all 10 prompts, mixed written/choice) → session completed → reveal shows both players' real answers with no "Skipped"; then played a complete This or That on both (5/5 "in sync" reveal) — confirming a second game is fully playable once the wheel is done and ToT requires a pick to advance (no skip). Ended at 0 active sessions (clean). How Well / Desire Sync left unverified-live (Desire Sync is premium→paywall; How Well unchanged) — both verified by code (require a selection to advance). Adjacent checks (no change): multi_choice has no minSelections (≥1 is correct); Daily Question already gated (canSubmit); reveal renders legacy "Skipped" as display ?: "—" (no crash). Test-data notes: cleared a stale stuck wheel session via the in-app reveal→markUserComplete path (admin write to flip it was classifier-denied, not worked around); live testing then created one new active wheel session (net-neutral) which blocked live-opening the other 3 games — those are verified by code (unchanged) + observed during Pass E. Uncommitted (user commits): WheelSessionViewModel.kt, WheelSessionScreen.kt, CategoryPickerScreen.kt, WheelSessionViewModelTest.kt, ClaudeReport.md.
  • R18b (2026-06-28) — Pass E full live re-run (user: "run ClaudeQAPLan pass E") — CLEAN, 0 P0/P1, 0 FATAL. Both emulators online (5554=QA, 5556=Sam, paired Xal3Kw3gjSdn0niERYKJ, both free), fresh FCM tokens (1 each). Cold-start crash-triage smoke 6/6 on BOTH (qa/entrypoint_smoke.sh: launcher + 5 push types am kill→real push→shade-tap→opens&stays, 0 fail/0 blocked) — the shared splash/onCreate path is clean. Routing (background→tap, landed-screen verified): 7 types received on Sam + 3 on QA (both-client) — chat→exact conversation, partner_answered & daily_question→Today, started_game & completed_part(tot)→game screen, finished_game(wheel)→per-session results (completed→results, not a dead active session), date_match→Your Matches; every tap correct destination + app alive + 0 FATAL. Foreground: partner_started_game→in-app banner (Join/dismiss) ; chat_message→draggable chat-head bubble (verified via real open→back→Home→send + distinct conv id; conversation_id=main suppression on a process-death-restored back stack is by-design read-suppression via ActiveThreadMonitor, clears on normal back-nav — not a defect). Malformed/stale (all graceful, 0 FATAL): unknown type→no nav/no crash; chat w/o conversation_id→Messages inbox; started_game w/o game_type→Play hub; finished_game w/ deleted session→graceful waiting state w/ escape. Payload privacy (P0) clean — code audit of all 6 senders (onMessageWritten/onGameSessionUpdate(+part-finished)/onAnswerWritten/onAnswerRevealed/createDateMatch/onCoupleLeave): data carries only routing IDs + optional public avatar URL, titles use display name only, bodies static; no message/answer/date/swipe content, no keys/codes/phrases; at-rest D1 cross-check — latest 6 conversations/main/messages all enc:v1:. NOT re-run this round: real in-app onMessageWritten send (UI-automation thrash on the composer send button) → carried from R18 live (exact copy, no content) + this round's code audit + at-rest D1; Doze/battery/App-Standby = blocked→needs-device (emulators can't enter those states — run on a physical device before store push). No app-code changes (pure QA round); touched ClaudeReport.md + ClaudeQACoverage.md + ClaudeQAPlan.md (added a Pass-E guard: qa_push.js reproduces the push but bypasses the Cloud Function trigger, so assertion #1 "trigger fires" needs ≥1 real in-app action per round) (user commits). Confirmed Navigation Compose restores the back stack across process death (launcher cold-start lands on the last sub-screen) — expected Android behavior, and the source of the bubble-suppression artifact above. NEXT: real-trigger live re-drive when convenient; physical-device Doze gate; continue other passes.
  • R18b (2026-06-28) — Future.md review → found+fixed a P0 (user: "review Future.md and do fixes if needed. verify bugs and why"). O-ONBOARD-001 (P0) — onboarding CRASHES on the final slide for EVERY fresh install (and the login/signup screen too). Verified live before/after on emulator-5558 (fresh, API 34): old build → onboarding slide-3 CtaSlideFATAL EXCEPTION: java.lang.IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported at PainterResources…loadVectorResourceOnboardingScreen.kt:246; fixed build → CtaSlide renders the logo + "Create account", and the signup screen (AuthLogoMark) renders too — full onboarding→signup reachable, 0 FATAL. Why (root cause, git-confirmed): ic_launcher_foreground.xml was a <vector> until commit 334cb07 "brand: update app icon" which swapped it to a <bitmap> wrapper; painterResource routes any .xml drawable through the VectorDrawable loader, which throws on a <bitmap> root. The two Compose call sites — OnboardingScreen.kt CtaSlide + AuthVisuals.kt AuthLogoMark — weren't updated. Regression invisible to recurring QA because 5554/5556 are past onboarding + logged-in (signed up before 334cb07); every fresh install since crashes. (Future.md's root-cause guess — background/aapt quirk — was wrong.) Fix: both sites now painterResource(R.drawable.closer_launcher_foreground) (the raster the <bitmap> wraps; same pattern LoadingState.kt:146 already uses); the <bitmap> XML stays for the real adaptive launcher icon. Scanned the whole app — no other painterResource-on-non-vector-XML remains (only ic_launcher_foreground/_monochrome are <bitmap>; monochrome isn't used via painterResource). Also fixed the remaining BucketList add-FAB hardcoded Color(0xFFB98AF4)MaterialTheme.colorScheme.primary (closes the Future.md "BucketList mixed dark/light" item — dialog was R16, FAB was the leftover; verified live light). Build clean; 205 unit + 24 functions green; all 3 emulators on the fixed APK. Regression guard ADDED + proven: scripts/painter-xml-scan.sh flags any painterResource(R.drawable.X) where X is a non-<vector> XML drawable (the exact crash class); demonstrated it catches the bug when reintroduced (exit 1) and passes clean on the fix (exit 0); wired into the plan's cheap-gates (step 3). Uncommitted (user commits): OnboardingScreen.kt, AuthVisuals.kt, BucketListScreen.kt, scripts/painter-xml-scan.sh, ClaudeQAPlan.md, Future.md, ClaudeReport.md, ClaudeQACoverage.md. NEXT: prune O-ONBOARD-001 after 1 confirm; the instrumented onboarding→signup smoke (androidTest, currently 0) remains a Future.md idea (would have caught this too).
  • R18 (2026-06-28) — continuing full run (user: "why are you stopping?" → don't hand back at checkpoints). Both emulators online (5554=Dark, 5556=Light, both reset to Device-default after testing); package is closer.app (launcher closer.app/app.closer.MainActivity). C-DARKART-002 FIXED + verified live across all 4 theme/art states (see Severity-board R18 note + the issue row): MainActivity now drives AppCompatDelegate.setDefaultNightMode from ThemeMode (sync initial read → no flicker loop; LaunchedEffect for runtime toggles), so every painterResource + BrandIllustration follows the in-app theme via the real Configuration uiMode. The previously-broken pack-art banners now render DARK in decoupled in-app-Dark + system-light, and the Today hero does too; symmetric in-app-Light + system-dark → light; both coupled states correct. C-DARKART-001 re-confirmed. The test-suite gate also caught TEST-002 (flaky MemoryCapsuleGenerator determinism test — un-injected System.currentTimeMillis() clock violated the documented "pure" contract; no production caller yet so zero runtime impact, but it intermittently reddened the suite) → fixed by injecting createdAtMillis. Build clean; 205 unit + 24 functions green. Uncommitted (user commits): MainActivity.kt, MemoryCapsuleGenerator.kt (+ its test), ClaudeReport.md. DONE this round: Pass A / B / E / L / P + M-001 confirmed (recommend prune). NEXT: prune C-DARKART-002 / TEST-002 / P-GRAMMAR-001 / M-001 after this confirm round; resolve O-AGE-001 (P2 pre-ship age gate — product call); P3 backlogs (BRAND-DARK-COVERAGE, BRAND-ICON-CUSTOM, C-ORIENT-001); optional deeper re-runs of F/G/I (last full sweep R12). Board: 0 open P0/P1 · 1 open P2 (O-AGE-001) · 3 open P3. Pass A (R18, live, Sam free on 5556 — both members confirmed free via admin read): premium gate enforced across two distinct surfaces — Desire Sync (game) → Paywall, premium Boundaries pack → Paywall; negative control: Mixed "Communication" pack opens and a free prompt is accessible (answer composer shown), so the gate isn't over-broad; Free filter shows a graceful "Nothing in free yet" empty state (catalog note: no fully-free packs). Paywall billing plans don't load on the non-GMS emulator ("Couldn't load plans / Try again") — expected Pass K env limit, degrades gracefully (no crash).
  • R17 (2026-06-28) — continuing full run (user: "complete full run, don't stop"). Both emulators on R16 build (5554=Dark, 5556=Light); HEAD 8b7bbc2 + working-tree fixes. Theme fixes confirmed LIVE (dark, 5554): C-THEME-005 (Wheel-History lock → surfaceVariant/primary), C-THEME-008/009 (Date-Match heart→primaryContainer + count badge→error) — joining 001/002 from R16. NEW finding C-DARKART-002 (P2): dark-variant art doesn't render in the decoupled in-app-Dark + system-light state — pack art (QuestionPackLibraryScreen:223 via packArtworkRes) + ~7 literal painterResource sites resolve -night off SYSTEM uiMode, not the in-app theme; proven live (in-app-Dark + system auto → light pack art; system night=yes → correct dark art). The BRAND-DARK-COVERAGE batch art is correct but only shows under system-night. Pass D1 at-rest = CLEAN (admin read R17): messages text, lastMessagePreview, Memory Lane capsule content+title, all 4 game answers (this_or_that/desire_sync/how_well/wheel) + date_swipe action all enc:v1:; only metadata in clear. Pass D3 = CLEAN (live raw-API R17): minted non-member token → couple doc / messages / capsules / desire_sync reads + premium self-grant all DENIED 403 (scratchpad/d3_negative.js). C-DARKART-002 fully diagnosed (routing through BrandIllustration is insufficient — createConfigurationContext doesn't resolve -night for these resources; recommended fix = sync config uiMode to in-app theme; my probe edit reverted; tree clean; build+units green; theme-scan CRIT still 0). NEXT (R18): C-DARKART-002 fix (uiMode-sync, architectural) + re-verify C-DARKART-001 holds; M-001 quiet-hours backgrounded-push re-test → prune; live-confirm C-THEME-004 + light-side spot-check; then Pass A (premium gate) / B (a game) / E (full notif) / L / P. Cornerstones D1+D3 this round.
  • R16 (2026-06-28) — full ClaudeQAPlan run STARTED. Session-start: both emulators online (5554/5556), HEAD 8b7bbc2, working tree carries a dark-variant art batch (pack_art_*_dark, together_empty/tonight_partner_prompt night variants → BRAND-DARK-COVERAGE progress) + my doc edits; baseline TBD. Cheap gates (new step 3): functions tests 24/24 ; Android unit tests found 5 failures → FIXED → 205 (TEST-001, test-vs-code drift: (a) PartnerNotificationManagerTest stubbed isInQuietHours(any()) but the method's default now: Calendar param pinned a stale instant that never matched the call-time clock → stub now any(), any(); (b) CloserBrandCopyTest ≤64 cap predated the intentional 150-char flagship primaryMessageBrandMessageRotator wraps it maxLines=3 — → cap now applies to short slogans only, flagship bounded 1..160. Both test-side only; production correct: quiet hours verified live R15, flagship is committed design 6d74c6a). theme-scan 🔴9/🟠8/🟡32 (the 9 CRITICAL = C-THEME-001..009 already filed). wiring-scan 🔴0/🟠20/🟡35 (🔴0 = Pass N DoD met). Done so far: rebuilt+installed both; smoke 5554 = 6/6 PASS, 0 blocked (launcher + 5 notif cold-starts open & stay), 5556 in progress. Theme triage — of the 9 filed C-THEME, 3 are NOT real shipped defects: C-THEME-003 = @Preview-only (WheelRevealPreview) → false positive; theme-scan now excludes @Preview composables. C-THEME-006/007 = dead unused PlaceholderScreen.kt (replaced by the real dashboard; 0 source refs) → file deleted. The other 6 are real → FIXED (theme tokens): BucketList (badge→primaryContainer; AddItemDialog surface→surface + Cancel→secondaryContainer + Add→primary + CategoryChips→primary/surfaceVariant — also closes the Future.md "mixed dialog" note), DateMatch (heart→primaryContainer, count badge→error/onError), WheelHistory (lock→surfaceVariant/primary), QuestionThread (waiting banner→surfaceVariant). theme-scan CRITICAL 9→0; build + unit tests green. R16 RESULT: smoke 6/6 both (0 blocked); theme-scan CRITICAL 9→0 (3 reclassified, 6 fixed); C-THEME-001/002 + N-001 + N-002 verified LIVE (dark, 5554); units 205 + functions 24 green; dead PlaceholderScreen.kt deleted; theme-scan now excludes @Preview. N-001/N-002 pruned. Open now: O-AGE-001 (P2 pre-ship) + 3 P3. NEXT (R17): live-confirm the 4 remaining C-THEME fixes (004/005/008/009) in both themes + the 6 in LIGHT on 5556; re-test M-001 quiet-hours (backgrounded-push) to prune it; then resume passes AN+P — esp. a live both-theme sweep of the new dark-art batch (BRAND-DARK-COVERAGE) + the D/E cornerstones. Uncommitted (user commits): unit-test fixes, 4 theme-fixed screens + BucketList/DateMatch, scripts/theme-scan.sh, deleted PlaceholderScreen.kt, dark-art batch, QA docs.
  • R15 (2026-06-27) — gap-closing round (Passes L/M/N/P + regression smoke) — found & FIXED M-001 (P2). Build current (HEAD c31eea2 + R15 working-tree changes rebuilt+installed both emulators); baseline both FREE, 0 active sessions. Smoke 6/6 GREEN both (launcher + 5 notif cold-starts). M (settings take-effect)M-001 (P2) quiet hours didn't suppress backgrounded/killed partner pushes (local-only window; OS shows notification block w/o app code). FIXED + verified live: client mirrors window+tz → users/{uid}; 4 partner-action senders suppress via fail-open recipientInQuietHours(); rules allowlist extended. Live: QH ON → fn log is in quiet hours — suppressing, 0 delivery; QH OFF → notified partner. Per-type chat toggle re-confirmed server-enforced (toggle off → 0 delivery; field flips in Firestore). Theme/DataStore persistence across relaunch . Biometric lock code-sound (no compose bypass; observation: re-locks on cold-start, not plain background→resume → Future.md). L (chat E2E) decrypt both dirs, attribution, Seen receipt, ❤️ reaction, ordering, day-sep, inbox no enc: leak, at-rest enc:v1:. N daily-Q + reveal both-answered gate render (outcomes/bucket-list/date-builder carried — render-clean prior rounds). P (content/language) UI copy warm/inclusive, debug rows BuildConfig.DEBUG-gated, friendly error fallbacks; question bank 6103 Qs: 0 empty/0 dupes/0 placeholders/complete answer configs/on-guide tone. D1 at-rest messages/preview/capsules enc:v1:. 0 FATAL. Pass N driven (user "FIX"): N-001 (P1) Bucket List was fully non-functional (coupleId never set → all CRUD no-ops) → FIXED + verified live (add enc:v1: / complete / delete / list render; client-only). N-002 (P2) "Plan a Date"/Date Builder "Create Plan" no-op (wrote to unread prefs collection; dateIdeaId/coupleId never wired) → FIXED + verified live (re-pointed DateBuilderViewModel to create a PLANNED DatePlan via savePlan + resolve coupleId → date_plan status=planned, enc:v1:; Home shows "Date coming up"). Outcomes/Your Progress code-correct (resolves coupleId); daily-Q/reveal render ✓. Uncommitted (user commits): client (BucketListViewModel, DateBuilderViewModel) — M-001's functions/rules/client were committed by the user mid-round (+ user dropped 3 dark-variant PNGs in drawable-night-nodpi/ toward BRAND-DARK-COVERAGE). M-001 functions+rules DEPLOYED to prod; N-001/N-002 are client-only (debug APK installed both emulators). NEXT (R16): confirm M-001 + N-001 + N-002 hold → prune; 2 P3 brand backlogs; revisit Date Builder "both-partners-generate" vision if wanted.
  • R14 (2026-06-27) — full fresh AJ ClaudeQAPlan run (pure QA, no code changes) — FLAWLESS, 0 open P0P3, 0 new findings. Baseline both FREE, 0 active sessions; R13 build on both emulators (5554 dark / 5556 light). A premium enforcement audited (6/6 features gated, not just badged; A-201 class closed) + free→Paywall (Date Match / Desire Sync / Question Packs) + couple-shared unlock (Sam prem→QA free unlocks Desire Sync + a premium pack; modal + subscription_entitlement_changed push delivered live to QA). B Desire Sync + How Well + Spin-the-Wheel full 2-device (True/False+Yes/No+multi-select+free-text answer types; skipped-answer reveal; first-finisher partner_completed_part nudge confirmed in Sam's queue), Memory Lane create+seal (premium), Connection Challenges resume (Day 4 · 🔥2), Date Match deck; ToT carried R13. C broad both-theme + decoupled-theme-art mandate (system-light+app-Dark → dark UI + correctly-themed feathered Today hero); no nav dead-ends (back-from-Home exits = correct). D LIVE non-member 403 ×2 · self-grant 403 · member 200 · chat at-rest enc:v1: (game/capsule at-rest carried R10/R12, crypto unchanged). E all triggers fired live, content-free copy to right partner (started/completed_part/finished + entitlement). F offline Today-from-cache + am kill recovery, 0 FATAL. I jank 5.25%. J J-OBS 48dp holds. 0 FATAL whole run. The 5 R13 fixes held → pruned. Uncommitted (user commits): R13's 16 modified + PremiumUnlockOverlay.kt + illustration_premium_unlock.png (R14 added no code).
  • R13 (2026-06-27) — backlog fix pass + full fresh AJ — FLAWLESS (0 open P0P3). Took over the open Codex dark-mode backlog and shipped it all (working tree, both emulators rebuilt+installed; baseline both FREE, 0 active sessions): C-DARK-UI-001 (ToT dark redesign — theme-aware backdrop/options/chips/versus/progress/pills) · C-DARK-UI-002 (check-in label/value weight) · C-DARK-UI-003 (Play/Home/Paywall bottom clearance) · C-ART-EDGE-002 (8 opaque heroes routed through BrandIllustration feather) · J-OBS (composer/voice/retry buttons → 48dp). Confirmed A-201 live → pruned. Shipped the Premium-unlock modal (ui/components/PremiumUnlockOverlay.kt, hosted in AppNavigation; driven off CouplePremiumChecker, one-time via a new premiumUnlockCelebrated SettingsRepository flag) — verified live on BOTH purchaser (5554) and partner (5556) + one-time gate (dismiss→relaunch no re-show). AJ: A ✓ (Date Match + Desire Sync gates → Paywall) · B ✓ (ToT full both themes; Wheel launch clean) · C ✓ (extensive both-theme sweep) · D ✓ LIVE (non-member 403 ×2, self-grant 403, member 200, chat at-rest enc:v1:) · E/F/G carried (diff is UI-only; no rules/functions/crypto/auth change) · H ✓ (ToT brand + modal + heroes) · I ✓ (jank 6.43%) · J ✓ (48dp). 0 FATAL both devices. Uncommitted (user commits): 16 modified + ui/components/PremiumUnlockOverlay.kt + res/drawable-nodpi/illustration_premium_unlock.png.
  • Ad hoc dark-mode UI/brand sweep (2026-06-27, Codex-owned emulator emulator-5558): current debug APK installed, dark mode forced, fresh real paired users created through invite flow (Codex Dark + River Dark). Swept profile, invite, paired Home, Play, This-or-That, Settings, Notifications, Paywall, Messages, Today. 0 app FATAL/ANR/force-finish in logcat. Findings added below: C-DARK-UI-001 (P2), C-DARK-UI-002 (P3), C-DARK-UI-003 (P3). Screenshots captured at /tmp/closer-dark-04-after-permission.png through /tmp/closer-dark-25-today.png. R12 (2026-06-27) FRESH FULL ClaudeQAPlan run STARTED (user: "start from the start") | Baseline verified clean: QA free, Sam free (premium revoked), 0 active sessions; build HEAD 2cd0af6 + 3 uncommitted art files installed both emulators (5554=Dark, 5556=Light) | A ✅ (A-201 P1 Date-Match premium bypass) | B ✅ (4 async games full 2-device end-to-end + first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; CC/MemoryLane/DateMatch render-checked; MemoryLane title/preview run-on→Future) | C ✅ (regression-clean vs R10 sweep; Messages/Today/Subscription + organic A/B + R11 decoupled art; NEW C-ART-EDGE-002 P3 direct-call hero hard edges on dark) | D ✅ (LIVE: D1 game answers enc:v1: · D3 non-member 403×4 + member-scoped · D5 self-grant→403; D2/D4/D6/D7 carried R7/R10, rules unchanged) — cornerstone clean | E ✅ (smoke 6/6 + Pass B triggers) | F ✅ (concurrency+process-death+offline) | G ✅ (security half live) | H (finding C-ART-EDGE-002) | I ✅ (jank 4.10%) | J (J-OBS P3) | **FIX PHASE ✅ — A-201 (P1) FIXED+VERIFIED LIVE** (Date Match LOVE/MAYBE on premium idea → Paywall via CouplePremiumChecker; SKIP passes; 0 FATAL). C-DARKART-001+C-ART-EDGE-001 held → PRUNED. | **R12 COMPLETE — FLAWLESS: 0 open P0P2.** Remaining: 2 non-blocking P3s (C-ART-EDGE-002 hero edges, J-OBS touch targets). | NEXT (R13): confirm A-201 holds → prune; optional P3 polish. Uncommitted (user commits): R11 art files + ui/dates/DateMatchViewModel.kt+ui/dates/DateMatchScreen.kt (A-201). Carryover from R11 (still valid): C-DARKART-001 (P2) + C-ART-EDGE-001 (P3) fixed pending 1 confirm; J-OBS (P3) open touch targets.
  • **(prior) R11 (2026-06-27) confirmation round COMPLETE — FLAWLESS | Fixed last open P2 C-DARKART-001 (in-app-theme art) + P3 C-ART-EDGE-001 (feathered edges) in shared BrandIllustration/EmptyState; verified live both decoupled theme directions, 0 FATAL | 5 R10 P2 fixes re-confirmed + PRUNED (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001) | entrypoint smoke green on fresh APK | 0 open P0P2 | Baseline: both FREE, 0 active sessions; build (HEAD 2cd0af6 + 3 uncommitted art files) installed both emulators | NEXT (R12 = next session): confirm C-DARKART-001 + C-ART-EDGE-001 hold → prune; optional J-OBS (P3) touch targets; then declare program-complete for Android. Art fixes in working tree (user commits).`
  • Pass B progress (R12): 1. This or That — full end-to-end 2-device, NEW style Light×5 Quick (R10 was Deep×10): QA started → answered 5 (alt A/B) → first-finisher state; first-finisher nudge fired (partFinishNotifiedAt set + Sam queue partner_completed_part "QA finished their part — your turn to play!"); Sam joined via Play-hub active state (at Q1/5, no dup session) → answered all-A → session→completed (0 active); partner_finished_game to BOTH; reveal 3/5 in sync symmetric + correct Match/Differ + You/QA attribution on both devices (QA dark / Sam light). 0 FATAL. 2. Spin the Wheel Ready=Start session (R11 change) verified; spun→Stress→10Q; mixed answer types (free-text + 15 scale) render+accept; Sam joined active session via Play hub (Q1, no dup/new spin); both finished→completed (0 active); results "Here's how you each answered" with You/QA free-text + scale; C-NAV-002 RE-VERIFIED LIVE — results → BACK → wheel hub (Spin/History), NOT the finished session (the R11-deferred confirm). 0 FATAL. 3. How Well — QA subject 5·Quick (answered 5 about self), Sam joined as guesser ("Predict how QA answered…", asymmetric), guessed 5 → score 5/5 "Perfect read" + per-Q breakdown (✓ + answer, choice+scale) symmetric both devices; completed (0 active). 4. Desire Sync (premium, couple-shared via Sam-on) — free QA opened setup (no paywall); QA all-Yes, Sam joined + Y,Y,Y,N,N → reveal "3 shared desires · 2 kept private" (only mutual-Yes shown, 2 mismatches "Private") symmetric both devices; completed (0 active); Sam premium restored OFF. All 4 async session games verified end-to-end.
  • Uncommitted (user commits): R11 art fixes — app/.../ui/theme/Theme.kt, app/.../ui/components/BrandIllustration.kt, app/.../ui/components/EmptyState.kt; + R12 A-201 fix — app/.../ui/dates/DateMatchViewModel.kt (CouplePremiumChecker gate + paywallRequired event) + app/.../ui/dates/DateMatchScreen.kt (navigates to paywall). Everything else committed in 2cd0af6. Build installed both emulators.
  • Foreground "partner started a game" alert + bold Game Waiting card (R10, user-requested) — DONE+VERIFIED. When the app is OPEN and a partner starts a game, a prominent in-app top banner (" started " + Join) slides in (mirrors chat's in-app surface) instead of the easy-to-miss system notification; Join → joins the game. Verified live for all 4 session games: banner name correct (Spin the Wheel / This or That / How Well Do You Know Me / Desire Sync); Join → joins wheel (ToT shows graceful "QA is playing a Wheel game" when types mismatch); suppressed when already on that game's screen (added ActiveGameSessionMonitor.enter/leave to WheelSessionViewModel — the others already had it). Home "Game waiting" card redesigned as a bold purple-gradient hero (glyph + game name + "Join the game"), promoted to top of "Waiting for you", verified both themes → tap joins the specific game (not the Play-hub fallback). FCM transport on the emulators is flaky (FcmRetry); the banner was exercised via a data-only high-priority send to the partner token (faithful to the deployed payload).
  • Pass C progress (R10): Settings family (dark + Security on light): Settings list, Subscription, Security, Delete account, Notifications all render clean both-relevant-themes; 4 illustrations confirmed in-context (Security padlock, Delete-account doorway, Quiet-hours moon, + Subscription); back-stack OK (Security/Delete/Notif → BACK → Settings). Found C-SEC-001 (P2) — accepter's Recovery phrase disabled + wrong "invite your partner" copy (see Open issues). Wheel back-stack RE-CHECKED = not a trap: live wheel session → BACK → spin/setup → BACK → Play hub (2 backs, no dead-end); earlier "stuck" was automation cycling. Leaving mid-wheel leaves a resumable abandoned active session (normal; cleaned). Home both themes (stale game card gone).
  • 6. Spin the Wheel — spun→Physical Intimacy→session; Sam joined at Q1; both answered 10 (A/B + 15 scale + free-text); per-Q You/QA breakdown renders; completed, 0 active. (Helper wheel_drive.py handles mixed types; free-text Qs hide "Next" behind IME.)
  • 7. Date Match — swipe deck ("Swiping with Sam/QA"); QA+Sam mutual like → "It is a match!" modal live; new match persisted (date_matches 3→4); swipe action enc:v1: at rest (only swipedAt clear).
  • Pass B = COMPLETE (R10): all 7 games played end-to-end 2-device, 0 bugs. 2 observations: CC day-counter desync (Future.md, by-design?) · WATCH — wheel back-stack: after finishing Spin-the-Wheel, system-BACK from the results re-enters the completed wheel-session screen (loop), needed an app relaunch to escape. Possibly automation artifact (missed taps) — recheck deliberately in Pass C nav fuzzing; file if reproducible (P2 back-stack).
  • 5. Memory Lane — new capsule sealed (3-mo pick) with future open date; title+content enc:v1: at rest (admin-verified); lists cross-session. Minor cosmetic: "Opens in 2 mo" shown for a 3-month selection (relative-time display nit; not filed).
  • 4. Connection Challenges — Gratitude Week (in-progress from R9): per-day step, "I did it today", "waiting for partner" both-gate, missed-day catch-up ("Pick it back up"), streak 🔥→2 synced both devices. UX note (Future.md): "Day N of 7" counter diverges between partners after asymmetric catch-up (QA D4/Sam D3) while streak stays synced — plausibly by-design, non-blocking.
  • Pass B progress (R10): 1. This or That — Deep×10 (varied): QA started, Sam joined via Play-hub card (no duplicate, 1 session), both answered 10, results symmetric both devices ("8/10 in sync", per-Q Match labels correct), session→completed, 0 stale. 2. How Well — QA-subject 5·Quick: QA answered 5 about self, Sam joined as guesser (asymmetric join works), predicted 5, score+breakdown render correctly (1/5, ✓/✗ guess→actual incl. scale Q), completed, 0 stale.
  • R10 scratchpad drivers (reuse): r10_set_premium.js <QA|Sam> <on|off> · rv_gate.js/rv_markreveal.js (raw-API) · hw_drive.py <serial> <rounds> (taps first option+Confirm per Q) · rv_inspect.js/rv_sessions.js (admin reads). Game-option taps: use uiautomator bounds, NOT fixed coords (layouts shift per question; last Q button = "Done →" not "Confirm →").
  • Admin writes: user authorized this session (2026-06-26) → premium toggle + baseline reset now working. Baseline reset done (0 active sessions; stale 06-24/06-25 answers cleared). Premium toggle: scratchpad/r10_set_premium.js <QA|Sam> <on|off>.
  • Pass A (R10): neither-premium → Desire Sync shows 🔒 + opens paywall ("Go deeper together"); toggled Sam premium ON → QA(free) Play hub badge cleared live + Desire Sync opens setup (no paywall) = couple-shared unlock holds. Code audit: all gates use CouplePremiumChecker except SubscriptionScreen (by-design own-status) + DailyQuestionResolver (per-user premium-question fallback — verify in Pass B/E it doesn't desync the couple's daily Q). Other 7 features share the verified path (R9 enumerated each).
  • Build: HEAD e6a8dee — clean working tree (reveal feature committed: couple-key encryption, read-gated secure subdoc, onAnswerWritten both-answered copy, onAnswerRevealed). Rebuilt + installed on both emulators this session.
  • Daily-reveal QA (2026-06-26, live, both emulators 5554 dark / 5556 light): Gate (raw API): only-1-answered → partner reads metadata 200 but content 403, non-member 403/403; both-answered → partners read each other 200/200, non-member still 403/403. At-rest: answer doc content-free metadata only; content in gated secure/payload (enc:v1:). Reveal: shows the partner's answer both directions (the fixed bug) — QA↔Sam. Pushes: onAnswerWritten fires (both-answered "unlocked " copy is in deployed code); onAnswerRevealed fired live (isRevealed flip → "notified partner that X opened"). 0 FATAL either device. Today's test answers wiped after; baseline clean. One low-sev robustness note → Future.md (reveal isRevealed write isn't retried if it fails). Note: stale active wheel session + 06-24/06-25 unrevealed answers are pre-existing test pollution (confound the Home dashboard daily card; not the reveal feature).
  • Devices / accounts: emulator-5554 = QA (Y05AKO2IlTPMa0JQW1BiNIM0uzK2) · emulator-5556 = Sam (imDjjO…) · paired, coupleId Xal3Kw3gjSdn0niERYKJ, both free (baseline restored).
  • Docs: Playbook ClaudeQAPlan.md · Coverage ClaudeQACoverage.md · Ideas Future.md ## QA · Branding ClaudeBrandingReview.md.

Severity board

Severity Open Fixed (pending 1 confirm)
P0 0 0
P1 0 0
P2 1 (O-AGE-001 pre-ship — needs product/legal) 1 (B-ABANDON-001 — fixed+verified live R20)
P3 1 (BRAND-DARK-COVERAGE — needs dark art assets) 1 (B-COPY-001 — fixed+verified live R20)

_R18b cleanup (2026-06-28): pruned the entire confirmed backlog (one-confirmation-round rule). Pruned to the archived line: O-ONBOARD-001 (P0, verified live R18b) · C-DARKART-002 (verified live R18) · C-THEME-001/002/ 004/005/008/009 (R16 fixes; 001/002/008/009 verified live, 004/005 via theme-scan=0 + same-batch sibling live-confirm

  • build) · M-001 (verified live R15) · TEST-001 / TEST-002 (re-confirmed green this round: 208 unit + 24 functions) · P-GRAMMAR-001 (asset fix) · BucketList-FAB · BRAND-ICON-CUSTOM (verified live R18) · C-ORIENT-001 → RESOLVED (portrait locked in the manifest + verified live: requestedOrientation=PORTRAIT holds under forced landscape). Only 2 items remain open, both blocked on the user (O-AGE-001 product/legal; BRAND-DARK-COVERAGE art)._

R16: ran the new cheap gates → found+fixed TEST-001 (unit suite was silently red, 5 failures) → 205 unit + 24 functions green. Entrypoint smoke 6/6 on BOTH emulators, 0 blocked. Theme triage: of the 9 filed C-THEME, 3 were not real shipped defects (C-THEME-003 @Preview false positive [theme-scan now excludes @Preview]; C-THEME-006/007 dead PlaceholderScreen [deleted]); 6 real → FIXEDtheme-scan CRITICAL 9→0, build+units green; C-THEME-001/002 verified LIVE (dark). Confirmed + pruned R15 fixes N-001 (P1, live add/delete) + N-002 (P2, Home "Date coming up"). M-001 carried (not re-tested this round). Remaining open: 1 P2 pre-ship (O-AGE-001) + 3 P3 (2 brand backlogs + C-ORIENT-001). 4 C-THEME fixes (004/005/008/009) pending live confirm next round.

R18 (2026-06-28): C-DARKART-002 FIXED + verified live (all 4 theme/art states). Root fix = drive the real Configuration uiMode from the in-app theme via AppCompatDelegate.setDefaultNightMode in MainActivity (read initial theme synchronously to avoid a placeholder→real recreation flicker loop; LaunchedEffect handles runtime toggles). This makes every painterResource site + BrandIllustration follow the in-app theme at once, retiring the unreliable per-site createConfigurationContext hack. Verified: coupled-dark→dark, coupled-light→light, decoupled in-app-Dark + system-light → dark (Today hero AND the previously-broken pack-art banners), decoupled in-app-Light + system-dark → light. C-DARKART-001 re-confirmed (holds). Running the new test-suite gate also caught TEST-002: MemoryCapsuleGenerator documents itself "pure/deterministic" but every factory stamped createdAt = System.currentTimeMillis(), so the same input produces identical capsules test was flaky across ms boundaries (passed alone, failed in the full suite). Fixed by injecting createdAtMillis (defaults to wall clock; tests pin it) — honors the contract; no production caller exists yet (Memory Lane not wired in), so zero runtime impact, but it was undermining the suite gate. 205 unit + 24 functions green; build clean.

R18 Pass A/B/P (live, 5556 Sam free): Pass A premium gate enforced on 2 surfaces (Desire Sync + Boundaries pack → Paywall), free content reachable (Mixed pack prompt opens), graceful empty/billing states. Pass B Wheel playthrough end-to-end (spin→Stress→start→MC + free-text answers→1/2/3 progression→End→async "answers are in, waiting for partner" reveal-gate); 0 FATAL either device. Pass P → P-GRAMMAR-001 (P3, FIXED in asset): the in-game wheel question surfaced a subject-verb agreement error; bank scan found 13 questions (all stress category, from one template family of 35) where plural subjects were substituted into a singular "{subject} is …" frame — "low energy days/busy weeks/health worries/burnout signs/unexpected problems is affecting you / is starting to show up". Fixed the 13 rows in app/src/main/assets/database/app.db (data-only UPDATE … REPLACE(' is ',' are ') on exact IDs stress_021/022/031/032/041/042/061/062/066/067/195/199/203; backup app.db.bak_pgrammar; verified 0 remaining). Room's identity hash is schema-based so the edit is safe; root/durable fix belongs in the content generator (build_db.py — NOT run per standing constraint) so regeneration doesn't reintroduce it (pluralize the verb or curate subjects). Caveat: running emulators copied the old asset on first launch and won't re-copy without a data wipe — source asset is corrected for new installs, verified via sqlite. Recommend a broader template-grammar audit of the bank (the prior completeness scan checked empties/dupes/placeholders, not agreement).

R18 Pass L (live E2E, 5556 Sam → 5554 QA): sent a uniquely-tagged message; received + rendered in plaintext on the partner device (E2E decrypt works), enc:v1: at rest (admin read: text=enc✓(79)), at-rest leak check passes (marker absent from all message docs — scratchpad/msg_atrest.js), "Seen" read receipt present, and the chat renders correctly in dark on 5554 (incidental C-theme confirm). Cross-checks D1 (message at-rest) on fresh live data.

R18 M-001 + Pass E (live): M-001 → confirmed (recommend prune). Toggling Quiet Hours on 5554 (QA) writes the client mirror to users/{QA} correctly — quietHoursEnabled:true, quietHoursStartMinutes:1320 (10 PM), quietHoursEndMinutes:480 (8 AM), timezone:"America/Chicago" — the exact fields the deployed recipientInQuietHours() reads; toggling off → enabled:false. The client-mirror half (the R15 fix) is intact; the server-suppression half is deployed + was live-verified R15. The full clock-windowed suppression was NOT re-run this round (emulator clock ~4 PM is outside the 10 PM8 AM window; doing it would need either clock manipulation or a per-occurrence-gated production write that the auto-classifier denied — not worked around). Restored QH to baseline (off). Pass E (real backgrounded delivery + privacy + deep-link): backgrounded QA → Sam sends chat → FCM notification delivered (channel=partner_activity, importance high, vis=PRIVATE) with title "Sam sent a message" / body "Tap to read and reply."no message content in the push (E2E ciphertext stays server-blind; privacy-safe, cross-checks D6). Clean Home-baseline re-test: tapping the notification deep-links to the correct conversation with messages decrypted. (A first attempt resumed at the prior screen — re-tested from Home and it routed correctly, so that was a test artifact, not a bug.)

Issues — open (Pass C theme defects + brand-asset backlogs)

Surfaced by the 2026-06-27 brand standards audit (new Pass H/Pass C mandates) and the 2026-06-28 theme-scan run. Brand-quality defects (light-only art, generic icons) and Pass C theme defects (hardcoded surface/background colors) both live here; asset lists + prompts are in ClaudeBrandingReview.md.

R16 reclassified (NOT real shipped defects, removed from open): C-THEME-003 = WheelCompleteScreen.kt:507 is inside @Preview fun WheelRevealPreview() — design-time only, never shipped → false positive; scripts/theme-scan.sh now excludes @Preview composables so it won't re-file. C-THEME-006 / C-THEME-007 = PlaceholderScreen.kt (SignalChip/PreviewPanel) had 0 source references (replaced by the real dashboard per docs/qa/private-mvp-checklist.md) → dead code, file deleted.

ID Sev Area Description Suggested fix Status
B-ABANDON-001 P2 Games lifecycle (Pass B) Quit/abandon on any game silently failed PERMISSION_DENIED, stranding the session active. QuestionSessionRepositoryImpl.abandonSession (and the dead twin GameSessionManager.finishGame) round-tripped through saveSession, which does a full doc.set() of a fixed 13-field map — dropping the server-only flags the Cloud Function wrote (startNotifiedAt/joinNotifiedAt/partFinishNotifiedAt). The session-update rule's affectedKeys().hasOnly([...]) counts those removed keys, so the write is denied; the session never flips to completed → stranded active (blocks starting new games), and the failure is swallowed by Log.d. Proven live (R20): Write failed at couples/.../sessions/MWkzZOWWRLrLNNoSwM0n: PERMISSION_DENIEDThisOrThatViewModel: quit-abandon no-op. Affects ToT/HowWell/Wheel (all route through abandonSession). Escaped R19 (only the arrayUnion paths were rules-tested). FIXED (R20): targeted update(mapOf("status" to "completed","completedAt" to now)) in abandonSession so affectedKeys == {status, completedAt} ⊆ allowlist + monotonic active→completed; finishGame now delegates to it. Verified live: Quit → no denial → active=0, then a different game started immediately (lockout resolved). Fixed — pending 1 confirm
B-COPY-001 P3 Content/copy (Pass P) · Home Home "GAME_WAITING" hero falsely claimed the partner already played. Body was hardcoded "Your partner already played their part — take your turn to reveal how you two line up," but the card fires on getActiveSessionForCouple()?.takeIf { uid !in completedByUsers } — and for async games completedByUsers stays empty until BOTH finish, so the card (and its false claim) shows the instant a game is merely started, even when neither partner has answered. Confirmed live (R20): with completedByUsers=[] + no answers, QA's Home showed the claim, then QA joined to a fresh Q1/10 (no reveal). Copy-vs-behavior mismatch. FIXED (R20): neutral, partner-named, always-accurate copy — "Game in progress / Pick up your game. / Jump back in to finish your picks and see how you and {name} line up." The accurate real-time "X played their part, your turn" nudge is still delivered by the push-driven YOUR_TURN GamePromptBanner. Verified live both devices (starter + joiner). Fixed — pending 1 confirm
O-AGE-001 P2 Release / store readiness (Pass O) No age gate / age verification despite adult-intimacy content. Sign-up collects only email+password+confirm; Create Profile collects name+gender; domain/model/User.kt has no DOB/age field; the only "birthday" in-app is the partner's relationship special-date (SpecialDatesSection), not age. Yet the app ships sexual/intimacy content (Desire Sync). Google Play content-rating + sexual-content policy generally require an accurate maturity rating and may require an age gate. (Static finding — 2026-06-28 QA-plan gap review; confirm against current Play policy + intended content rating.) Add an 18+/age-appropriate gate where required + complete the Play content/maturity questionnaire to match actual content. Pre-ship gate (does not block per-round flawless). Open (pre-ship)
BRAND-DARK-COVERAGE P3 Art / theme Most illustrations are light-only — only 12 of ~25 have a drawable-night-nodpi/ dark variant. All illustration_couple_* heroes (paywall/subscription/onboarding/invite/history), daily_question, partner_activation, tonight_partner_prompt, together_empty, and all 10 pack_art_* banners show the light/pink image on a dark screen (feathered edges don't change the image colors). Generate dark/aubergine-palette variants for each light-only asset → drawable-night-nodpi/ (identical filename); BrandIllustration auto-selects per in-app theme. Re-run the decoupled-theme check. List in ClaudeBrandingReview.md. Open (P3)

Resolved & confirmed (archived — full detail in git history)

O-ONBOARD-001 · C-DARKART-002 · C-THEME-001 · C-THEME-002 · C-THEME-004 · C-THEME-005 · C-THEME-008 · C-THEME-009 · M-001 · TEST-001 · TEST-002 · P-GRAMMAR-001 · BucketList-FAB · BRAND-ICON-CUSTOM · C-ORIENT-001 · A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS · N-001 · N-002 — all fixed and re-verified (R16 pruned N-001 [Bucket List non-functional → CRUD works; confirmed live add/delete] + N-002 [Date Builder no-op → Home "Date coming up"; confirmed live]) (R14 pruned the 5 R13 fixes — C-DARK-UI-001 ToT dark redesign · C-DARK-UI-002 check-in label/value · C-DARK-UI-003 bottom-inset clearance · C-ART-EDGE-002 8 opaque heroes feathered · J-OBS 48dp touch targets — held through R14's full AJ sweep; in working tree) (R13 pruned A-201 [Date-Match premium ideas ungated → now gated to Paywall via CouplePremiumChecker] — fixed R12, confirmed live R13; in working tree) (R12 pruned C-DARKART-001 [in-app-theme -night art] + C-ART-EDGE-001 [feathered edges] — fixed R11, held through R12 visual sweep; in working tree) (R11 pruned the 5 R10 P2 fixes — C-HOME-001 single Home card · C-NAV-002 popUpTo(WHEEL_SESSION){inclusive} present + R10-live · C-NAV-003 single app bar re-confirmed live · C-PW-001 dark paywall pills legible re-confirmed live · C-SEC-001 recovery row active for accepter re-confirmed live — all committed in 9c84c36; E-GAME-003 onGamePartFinished deployed + committed 2cd0af6) (E-GAME-002 confirmed live R10: startNotifiedAt set + partner_started_game queued to right partner + foreground banner + Join→joined active ToT at same Q1; commits 6e79cd9/38fdc6d) (commits in history; F-RACE-001 re-confirmed R8; I-001 query→whereIn(dayKeys) + I-002 Long-score→Number.toInt(), fixed ab29f6b, re-confirmed live R9: 0 outcomes denials/CCE). Pruned per the one-confirmation-round rule. (C-OBS / outcomes list / SubscriptionScreen per-user gate = investigated, not bugs.) (R18b pruned the full remaining backlog — O-ONBOARD-001 · C-DARKART-002 · 6× C-THEME · M-001 · TEST-001 · TEST-002 · P-GRAMMAR-001 · BucketList-FAB · BRAND-ICON-CUSTOM · C-ORIENT-001 [portrait lock] — all verified live in their fix rounds + re-confirmed via 208 unit/24 functions green this round; in working tree, user commits.)

Security cornerstone — clean (Pass D, deep dive, Round 7)

  • R17 re-verified (live, admin + raw-API): D1 at-rest — messages text, lastMessagePreview, Memory Lane capsules (content+title), all 4 game answers (this_or_that/desire_sync/how_well/wheel), date_swipe action → all enc:v1:; only metadata clear. D3 negative access — minted non-member token → raw Firestore REST: couple doc / messages / capsules / desire_sync reads + premium self-grant all DENIED 403. Scripts: scratchpad/d1_atrest.js, d1_probe3.js, d3_negative.js.
  • D1 at-rest: chat text + lastMessagePreview + all 4 game-answer collections (ToT / How Well / Desire Sync / Wheel, both users) + Memory Lane capsules + date-swipe actions = enc:v1:. No plaintext content; only metadata in clear.
  • D2/D3 access: non-member denied all reads/writes (raw Firestore REST → 403); real premium write users/{uid}/entitlements/premium denied (server-only → no self-grant); cross-couple denied.
  • D4 keys: 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).
  • Robustness: malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.

"All notifications broken / app opens-and-closes" — ROOT CAUSE = splash crash (FIXED R10)

The actual cause was NOT routing — it was a crash in the splash-screen exit animation on notification cold-starts. MainActivity.onCreate (added in 95cad84, 2026-06-25) set splashScreen.setOnExitAnimationListener { provider -> provider.iconView.animate()… }. On a notification / PendingIntent cold-start the OS hands the splash view over without an icon (SplashScreenView: Icon: view: null), and provider.iconView throws an internal NullPointerException (SplashScreenViewProvider$ViewImpl31.getIconView) → onCreate crashes → "Force finishing activity" → the app opened and immediately closed on EVERY notification tap (chat, game-start, results — all of them, because they share the cold-start path). This is why it looked like "all notifications broke again." Normal launcher cold-starts were fine (icon present), which masked it.

  • Why my earlier am start tests missed it: shell am start uses a different splash transfer than the FCM PendingIntent handover (the SysUILaunch remote transition), so it didn't hit the null-icon handover. Also am force-stop can't receive FCM at all (stopped-package broadcast exclusion) — must use am kill to test killed-app push.
  • Fix (R10, working tree): MainActivity wraps the icon scale in runCatching (best-effort) and the view fade in runCatching { … }.onFailure { provider.remove() } so the splash is always removed and onCreate never crashes.
  • Verified live: real FCM notification → killed (am kill) Closer2 → tapped the OS notification → cold-start logs Icon: view: null then remove starting view, 0 FATAL, process stays alive, lands on Home (was the crash). Normal launcher cold-start still animates + works.

Invariant: an app-posted notification carries the resolved route in one place — the app_route extra — and routing is MainActivity.deepLinkRouteFromIntentpendingDeepLinkAppNavigation navigateRoute. Do not also set an ACTION_VIEW + closer:// data Uri on the notification intent: for routes that have a navDeepLink (conversation / answer_reveal / daily_question / question_thread / home / play) the NavController auto-handles that Uri in addition to pendingDeepLink → a race/duplicate nav. That dual path is what kept re-breaking notifications.

  • Why it broke "again" (root cause, traced via git): aaab768/1b9d8cf/b9b1560 built routing on the closer:// data Uri (NavController auto-handle) + a pendingDeepLink gated on currentRoute == HOME; then 38fdc6d added the app_route extra on top without removing the data Uri → two mechanisms for the same tap. The HOME-only gate also meant a warm tap from any non-Home tab set pendingDeepLink but never consumed it.
  • Fix (R10, working tree): PartnerNotificationManager.showNotification no longer sets ACTION_VIEW/data Uri — app_route extra only. AppNavigation pendingDeepLink gate broadened from == HOME to !in entryRoutes (fires once past onboarding, on any main screen). Verified live (0 FATAL): killed-app tap → chat opens the conversation; all 4 game results pushes (partner_finished_game) load the real per-session results (wheel "Here's how you each answered" · This-or-That "5/5 in sync" · How Well "Perfect read 5/5" · Desire Sync "5 shared desires"); app_route-only path (no Uri) loads results; warm tap from Settings now routes (was the stuck case).

Round history (one line each)

  • R14 (2026-06-27) — full fresh AJ run (pure QA, no code), FLAWLESS, 0 new findings. Confirmation round on the R13 build: A premium enforcement audit + couple-shared unlock + entitlement push live; B 3 async games full 2-device + first-finisher nudge + Memory Lane/CC/Date Match core; C decoupled-theme-art mandate; D cornerstone live (403s + enc:v1:); E triggers/copy live; F offline + process-death; I jank 5.25%; J 48dp holds. 0 FATAL both emulators. The 5 R13 fixes held → pruned to the archived line.
  • R13 (2026-06-27) — open-backlog fix pass + full fresh AJ, FLAWLESS (0 open P0P3). Fixed all 5 carried/open UI items (C-DARK-UI-001 ToT dark redesign · C-DARK-UI-002 check-in label · C-DARK-UI-003 bottom insets · C-ART-EDGE-002 8 opaque heroes feathered · J-OBS 48dp targets), confirmed A-201 live → pruned, and shipped the branding Premium-unlock modal (one-time, both partners, couple-shared). AJ: D security cornerstone re-verified LIVE (non-member 403, self-grant 403, at-rest enc:v1:); premium gates → Paywall; ToT both themes; jank 6.43%. Diff UI-only → E/F/G carried. 0 FATAL both emulators. App changes in working tree (user commits).
  • R12 (2026-06-27) — FRESH FULL AJ run + fix phase, FLAWLESS (0 open P0P2). Found A-201 (P1): Date Match premium ideas ungated (free users could like/match ★Premium ideas — getDateIdeas()=all, no checker, badge only; escaped prior Pass A rounds) → fixed + verified live (gated LOVE/MAYBE via CouplePremiumChecker→Paywall, SKIP passes). Pass B: all 4 async games full 2-device E2E (ToT/Wheel/HowWell/DesireSync) + first-finisher nudge + C-NAV-002
    • Ready=Start re-verified live. Pass D LIVE clean: non-member 403 (read+write), self-grant→403, game answers enc:v1:. Pass E smoke 6/6. Pass I jank 4.10% (art change perf-safe). New P3 C-ART-EDGE-002 (direct-call hero hard edges, deferred). C-DARKART-001+C-ART-EDGE-001 (R11) held → pruned. Retrospective added to Pass A (badge≠gate; try to USE premium content as a free user). Fixes in working tree (user commits).
  • R11 (2026-06-27) — confirmation round, FLAWLESS (0 open P0P2). Fixed the last open P2 C-DARKART-001 (dark-mode art now follows the in-app theme: LocalAppInDarkTheme CompositionLocal in CloserThemeBrandIllustration loads the -night drawable via a createConfigurationContext whose UI_MODE_NIGHT_* comes from the app theme, not the system) and the open P3 C-ART-EDGE-001 (tiled art feathers its 4 edges to transparent via graphicsLayer{Offscreen} + BlendMode.DstIn gradients instead of hard clip+border; EmptyState now routes through BrandIllustration). Verified live both decoupled theme directions (5554 system-light+app-Dark → dark aubergine art; 5556 system-dark+app-Light → light pastel art; both feathered), 0 FATAL, both apps alive. Re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001 single Home card · C-NAV-002 wheel-back popUpTo present · C-NAV-003 single app bar live · C-PW-001 dark paywall pills legible live · C-SEC-001 recovery row active for accepter live). Entrypoint launch-integrity smoke green on the fresh APK (launcher
    • notification cold-starts open & stay — splash-crash class clean). Art fixes in working tree; everything else committed (2cd0af6).
  • E-GAME-003 (2026-06-27) — FIXED+VERIFIED+DEPLOYED: async-game first-finisher left the waiting partner un-notified. Async games (this_or_that/wheel/how_well/desire_sync) write answers to couples/{c}/{gameType}/{sessionId} and the session only flips to completed when BOTH answer — so onGameSessionUpdate (watches the session doc) never fired on a single finish, and the waiting partner got nothing ("Closer2 finished a game but the partner was never notified"). Fix = new Cloud Function onGamePartFinished (trigger on the answer doc; on exactly-1 answer, idempotently claim partFinishNotifiedAt on the session + send partner_completed_part "X finished their part — your turn to play!"). Verified live: QA finished ToT part → session partFinishNotifiedAt=true, Sam queue got 1 partner_completed_part, posted on Sam's device, tap → opened ToT, 0 FATAL. Deployed (onGamePartFinished created, onGameSessionUpdate updated). Funcs source uncommitted (user commits).
  • R10 (2026-06-26) — FULL ClaudeQAPlan run AJ + fix phase. Found 5 P2 in report-only passes, fixed + verified all live: C-HOME-001 (Home dup pending card), C-NAV-002 (wheel results→BACK re-entered finished session), C-NAV-003 (duplicate app bar on Wheel History/PartnerHome), C-PW-001 (dark paywall pills light-on-light), C-SEC-001 (Security read wrong recovery-phrase store → accepter couldn't view phrase; E2EE recovery itself sound). E-GAME-002 confirmed live (startNotifiedAt set + partner_started_game→right partner + foreground banner + Join→joined active ToT) → pruned. D1D7 security clean (non-member denied all raw-API reads/writes, no self-grant, secure-subdoc gate correct, argon2id+AAD=coupleId). Concurrency double-start→1 session. Perf jank 5.53% / a11y font-2.0 reflows — no regression. Build OK, both emulators reinstalled, 0 FATAL, content still enc:v1:. App fixes in working tree (user commits).
  • Notif→game fix + dark art + QA sweep (2026-06-26, uncommitted). E-GAME-001 (P1, FIXED+VERIFIED): game notifications "led nowhere" — backgrounded/warm taps landed on Home (MainActivity was standard launch mode → onNewIntent never delivered the tap's extras → pendingDeepLink unset), and even when routed, the game screen showed setup instead of joining (one-shot getActiveSessionForCouple raced the post-push Firestore sync → returned stale-empty). Fixes: AndroidManifest MainActivity launchMode=singleTop + QuestionSessionRepositoryImpl.getActiveSessionForCouple now SERVER-first (cache fallback). Verified live: Sam backgrounded → taps partner_started_game → lands IN the active This-or-That (1/10), joined, no duplicate session; back-stack sane (game→back→Home→back→exit, C-NAV-001 holds). Generic across game types (shared routing + getActiveSession). Dark-theme art: 12 _dark variants → drawable-night-nodpi/ (light names) so dark mode auto-swaps; verified live (Security shows the aubergine variant on dark; light unchanged). QA sweep: tabs both themes, deep-link back-stack, all 12 illustrations both themes — 0 FATAL, baseline intact.
  • Brand art drop (2026-06-26) — wired + QA-swept, 0 issues. All 11 generated illustrations (A1A12, source gitignored) wired into their screens via shared EmptyState + new BrandIllustration helper (commits 077a4085868d06). Complete both-theme sweep: in-context dark and light verified for Bucket List (A6), Quiet hours (A9), Security (A11), Delete account (A12) — all render as crisp rounded tiles, on-brand, no clipping/contrast issue; A1 (transparent), A3 (banner) + the empty-only states (A2/A4/A5/A8/A10, unreachable on the baseline couple) verified via the debug Art-preview gallery on both themes + the proven shared tile. 0 FATAL/ANR both devices; baseline intact (0 sessions/outcomes). Process catch: 5556 was on a stale build mid-sweep → reinstalled current, both now on 768f511. Details in ClaudeBrandingReview.md.
  • R9 — clean confirmation round (0 new findings): confirmed + pruned I-001/I-002 (0 outcomes denials/CCE on the fixed build); swept deferred Pass C deep/list screens (Answer History, Activity, Bucket List, Date Match/Matches — both themes) + Pass F network (offline cache render + clean reconnect). 0 open P0P2.
  • R8 — F-RACE-001 re-confirmed + pruned; Passes I (perf) + J (a11y) run; found+fixed+verified I-001 & I-002 (outcomes read: query rules-denied + Long/Int parse CCE → "Your Progress" was silently dead). 0 open P0P2.
  • R7 — multi-angle security/concurrency deep dive → cornerstone fully clean; F-RACE-001 found + fixed + verified. 0 new open.
  • R6 — branding drop + Future.md backlog regression (white-keyhole icons/loader/splash, inclusive gender, copy, rate-limit split, results-push suppression, paywall retry/offline) → 0 new open.
  • R5 — Cloud Functions deployed (E-OBS channel fix, E-003 results routing) + new Pass G (account creation / fake-account abuse) clean → 0 open.
  • R1R4 — baseline Passes AF report-only; every P0P2 found was fixed + verified (see archived IDs).

Operational constants

  • Execution mode: autonomous run-to-completion — don't stop; fix blockers inline; cycle fix→re-QA until flawless. Don't hand back when context fills — re-read this run-state + coverage after any compaction. 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 without pausing. Only the macOS requirement for iOS (Parts 2/3) is a hard stop.
  • Hardening backlog → Future.md: App Check not enforced on Firestore. (Correction R15: the users/{uid} update rule is NOT open — it enforces a field allowlist (firestore.rules ~L198, hasOnly([...])); R15 extended it for quietHours*+timezone. Keep that list in sync with FirestoreUserDataSource when adding a client-written field.)