Closer/ClaudeReport.md

243 lines
137 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Claude QA Report — Full-App QA (living report)
> **Verdict (2026-07-02): R28 = closed R27's two gaps — FIXED HW-BREAKDOWN-001 (P3) + live-ran the 3 premium games 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL; open P3 board now clear except the 2 brand-asset backlogs.** **HW-BREAKDOWN-001 (P3) FIXED:** How Well results breakdown was rendering an un-guessed/fallback choice as its raw option-ID slug (`a_small_romantic_surprise`); added `humanizeOptionId()` (`_`→space + Title-case) applied to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win when present) — built + installed on both emulators. **Premium grant (user-authorized THIS occurrence: "grant QA premium via admin write, test the 3 premium games, then revoke") → couple-shared unlock re-confirmed live:** granting QA (`entitlements/premium` hasPremium/isActive/premium=true, source `qa_admin`) unlocked BOTH partners — Sam's (free) Play hub dropped the 🔒 on Desire Sync/Memory Lane and QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal (Pass A partner→both, live). **Premium games — all 3 PASS live 2-device:** ① **Desire Sync** — both answered privately (Sam Y·Y·Y·Y·Y, QA Y·Y·Y·N·N) → both devices rendered the **identical intersection: "3 shared desires / 2 answers stayed private"** listing exactly the 3 mutual-YES prompts (the 2 QA said No to stayed private), You/partner chips both "Private", and Sam's waiting screen **auto-flipped** when QA finished + fired the in-app **"You both finished — View"** banner. ② **Memory Lane** — created + sealed a capsule ("R27-Capsule-TITLE" / body "R27-SEALED-BODY-do-not-leak", 1yr → opens Jul 2 2027); the list row shows **only the title under a lock** ("Opens in 12 mo") — **sealed body does NOT leak** (confirms the odd-looking pre-existing rows like "R10_Memory_Testy…" are just long *titles*, not body previews). ③ **Date Match** — mutual like on "Sunrise hike + thermos coffee" (liked on QA, then Sam) produced a **"Matched"** entry in the couple-shared "Your Matches" list **on both devices** (Mutual love ×4, each with "We did this"); premium-tagged idea "Overnight camping getaway" is now swipeable/matched (A-201 gate lifts correctly under premium). **Premium REVOKED after testing (fulfilling the authorization):** admin set hasPremium/isActive/premium=false + `revokedAt` — verified at DB (BEFORE all-true → AFTER all-false) **and live in UI** (Memory Lane + Past Games show 🔒 **Premium** again; free fixture state restored). **Copy nit BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** the foreground game-results banner read "See how you and **Your partner** compare" — the generic fallback is capitalized (correct at sentence-start for the started/joined/your-turn lines) but reads wrong mid-sentence. `GamePromptBanner.styleFor()` now branches the RESULTS line2: resolved name → "See how you and Sam compare"; no name → name-free **"See how you both compare"** (never an awkward capitalized generic). Compiles + installed both emulators. **O-AGE-001 (P2 pre-ship) — IMPLEMENTED + live-verified (18+ DOB gate):** new `domain/AgeGate` (18+, DOB→age math, 8 unit tests); `User.birthDate: Long?` + Firestore read/`createUser`/`updateBirthDate` + `firestore.rules` users-update allowlist `+birthDate`; `SignUpScreen` now has a **Date of birth** picker ("Closer is an 18+ app.") validated **before account creation** (under-age → no account); `CreateProfileViewModel/Screen` add a conditional first **DOB step** for arrivals without a DOB (Google/legacy) and skip it for email users (DOB carried via a `SignupHandoff` singleton — the post-signup Firestore write races auth-token attachment, so we validate at sign-up but persist at profile). **Live-verified on throwaway 5558 (fresh installs, own account):** DOB blank → "Please enter your date of birth."; **Jul 5 2008 (age 17) → "You must be at least 18 to use Closer." + NO account created** (stays on sign-up); adult DOB (2000/2001) → account created → CreateProfile opens at **NAME (Step 1 of 3), DOB step correctly skipped** for the email user; full profile save **succeeds** (no error). **Landmine caught live (would've shipped a broken flow):** the first attempt persisted birthDate via `updateBirthDate` (an *update* — the doc already exists from the FCM-token write), which the **deployed** rules reject (my allowlist add is undeployed) → **PERMISSION_DENIED broke the whole profile save**. Fixed by making the birthDate persist **best-effort** (`runCatching`, never blocks profile creation — the gate is already enforced client-side). **birthDate persistence: the additive `firestore:rules` allowlist change was DEPLOYED by the user — verified via the exact app update path (authed `PATCH {birthDate,lastActiveAt}` on own doc → 200 ALLOWED + persisted; was 403 pre-deploy) and confirmed the allowlist still rejects a non-allowlisted field (→ 403), so the deploy widened only `birthDate` and weakened nothing.** birthDate now persists end-to-end; the best-effort write also keeps profile creation resilient. Unit suite **279 green** (+8 AgeGate). Throwaway test accounts created + deleted; 5558 shut down (QA/Sam fixtures untouched). **0 FATAL** across the whole session, both emulators. All code + docs uncommitted (user commits); **firestore.rules changed (undeployed).**
>
> **Verdict (2026-07-02): R27 = full-plan COMPLETION — live-ran every pass R26 had carried; 0 P0/P1/P2, 1 new P3 (HW-BREAKDOWN-001), 0 FATAL.** Same build as R26 (UI-only diff; app unchanged, only doc edits since). Closed the gap I was honest about: **P** (question bank — 6103 Qs 0 empty/dupe/placeholder, configs complete, daily pack 500 intact, **Room hash `7e7d78…` preserved**), **I** (perf — core-tabs 6.67% janky/90th 31ms, conversation scroll 3.04%, 0 missed-vsync), **J** (a11y — font 2.0 reflow with no hidden actions, reduce-motion no hang, **TalkBack 160/160 icons labelled**, 48dp targets carried), **F** (resilience — Messages renders from cache in airplane-mode 0-FATAL, portrait-lock holds under forced rotation, process-death via smoke), **G/D3** (security — **live raw-API: non-member 403 on couple/messages/daily/date_reflections/user + self-grant 403**; own-doc 404 = rules are the gate), **H** (branding on-brand; 2 P3 backlogs carried). **Pass B games (live 2-device):** This-or-That 5/5 (R26), **How Well** 2-of-3 with correct scale+choice scoring, **Connection Challenges** resume→complete Day6→advance Day7→mutual per-day gate + streak/missed-day recovery, **Spin-the-Wheel** spin/category/session/answer/cap/quit (full 10-Q carries R18b). **NEW — HW-BREAKDOWN-001 (P3):** How Well results breakdown shows a wrong *choice*-guess as its raw option-ID slug (`a_small_romantic_surprise`) instead of the human label ("Only if needed" resolves correctly) — cosmetic ID-leak in the (untouched) How Well feature; recommend resolving the guessed option ID to its display text in the breakdown row. **Premium games (Desire Sync, Memory Lane, Date Match):** paywall GATE verified for all three (free → Paywall; Date Match free-swipe→paywall = A-201 holds); **GAMEPLAY was blocked this round — the admin premium grant was declined by the auto-mode classifier (not the specific action authorized by "run the full qa"); needs explicit OK. → RESOLVED in R28: user explicitly authorized the grant, all 3 premium games ran full 2-device, then premium was revoked (see R28 verdict above).** **K** (real money path) + **O** (release build/store) remain pre-ship/needs-device. All docs uncommitted (user commits).
>
> **Verdict (2026-07-01): R26 = full-plan run on the text-input/truncation + DateReflection-hardening build — 0 defects, 0 FATAL.** Round validated this session's UI-only changes (display-truncation removed from content/error surfaces; free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; spin-the-wheel written-answer cap added; near-limit counter; DateReflection read-failure→retryable ERROR + bounded couple-read) and closed the last outstanding coverage item. **Cheap gates GREEN:** unit **244** · functions **47** · theme-scan CRIT **1 = false-pos** (HomeScreen:829 brand count pill) · painter-xml **0** · wiring 🔴**0** dead · ime-scan **PASS**. **Cold-start `entrypoint_smoke` 6/6 on BOTH emulators** (launcher + 5 notif cold-starts open & stay). **QA(5556) fixture RE-RESTORED (user-authorized):** an environmental logout after the standby emulator kill (couple key + recovery intact on disk) → **admin Auth password reset + sign-in, no restore ceremony**; QA landed on paired Home with daily-Q + partner state + full chat history decrypting. **Pass D (E2EE at-rest, admin ground-truth) CLEAN:** conversations (main + per-question discussion), daily-question answers (both users), date_reflections all `enc:v1:` with content-free metadata; image messages carry only an encrypted `mediaUrl` (no text/plaintext); **no rules or crypto changed this cycle**, so R25's D2 static + D3 live-negative results carry. **Pass L (messaging) PASS:** QA inbox previews fully decrypted (no `enc:` leak), full thread decrypts with attribution/day-separators/Seen, and a **live 2-device round-trip** (QA→Sam: "QA_roundtrip_passL_restore" delivered + decrypted on Sam) — proving the *restored* couple key still produces ciphertext the partner decrypts (R24 restore-key integrity). **Pass A PASS:** free (Sam) → Desire Sync → **Paywall** "Go deeper together" with legible dark-mode benefit pills (C-PW-001 holds). **Pass M PASS:** Settings renders clean (profile card, Connected-with, sections; debug rows `BuildConfig.DEBUG`-gated; no clipping). **Pass C:** every screen driven this round renders clean in dark; theme-scan unchanged (1 known FP). **Pass N — Date Memories/Reflection (the R25 `todo`) CLOSED:** 2-device reflect→reveal, edit-before-reveal (against deployed rules), the new notes field, background + foreground deep-link into the exact reflection, and the `date_reflection_ready` / `date_reflection_opened` pushes were all verified live this session, plus the R25-fixed hardening (own-status read-failure → retryable ERROR with Try-again, 8s couple-read timeout, blank-dateId guard). **Pass B (games) — This-or-That re-run live 2-device:** start (Sam) → waiting-for-partner gate → **join from QA's foreground game banner** → both answer 5 → **"5/5 in sync · Two peas in a pod" results rendered symmetrically on BOTH devices** (per-question You/partner breakdown, answers decrypted, all Match); incidentally re-confirmed the live foreground game banners (`partner_completed_part` "Your turn — reveal how you line up" + results "You both finished · View", each with the partner avatar) and real-time reveal sync (Sam's waiting screen auto-flipped when QA finished). **Not run (documented, carried / pre-ship):** K real money path (needs device + Play sandbox), O minified release/store readiness, device/OS matrix (two identical emulators), and the remaining 6 games' Pass-B re-run (no games-logic change this cycle; smoke covers all game cold-start paths, last full 7-game B clean R12/R18b). All code + docs uncommitted (user commits).
>
> **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`](docs/Engineering_Reference_Manual.md). Most fixed-and-pruned IDs above are documented in its [Known landmines and recent fixes](docs/Engineering_Reference_Manual.md#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)
- **R25 (2026-06-30) — full fresh ClaudeQAPlan run focused on the new R24 E2EE backup/restore surface + cornerstone regression. 0 new defects; recurring bar stays clean. Two user-gated items surfaced (functions deploy + a fixture-recovery I caused).** **Cheap gates all green:** unit **244** · functions **38** (incl. new `onRestoreRequested` tests) · `theme-scan` CRITICAL **1 = false-positive** (`HomeScreen.kt:829` `Surface(color=CloserPalette.PinkBright)`+white text is a brand-accent count pill like the legible Settings "3" badge — not an adaptive surface; MAJOR/REVIEW all pre-existing-acceptable) · `painter-xml-scan` **0** · `wiring-scan` 🔴**0** dead setters / 🔴**0** dead notif settings (20 🟠 orphan-readers = known interface-decl false-positives) · **cold-start `entrypoint_smoke` 6/6 on BOTH emulators** · **`connectedDebugAndroidTest` FirstRunRenderSmokeTest 4/4** (first-run composables paint light+dark). Monkey fuzz skipped (AVD activity-resolution quirk, not an app bug). **Pass D (security) — CLEAN, deep on the new backup/restore surface:** D1 at-rest (admin ground-truth) — backup `manifest` holds pointers/metadata only (generation, messageCount=33, `sha256:` checksum, tokenized `snapshotUrl`, uids); the Storage **snapshot blob is `enc:v1:` ciphertext** (16455 B, 0 plaintext markers in first 2KB — server-blind); loose chunks folded to snapshot (0 now; rule enforces `isCiphertext(payload)`); `restore_requests`=0 (clean, no lingering keybox). D2 rules static — `backup/**` + `restore_requests/**` member-scoped; keybox handover bound to the couple's **other** member with immutable pubkey/nonce; recipient-only create/delete; status-flips constrained; Storage `users/{uid}/backups` uploader-scoped **write**, **read** via tokenized capability URL. D3 **live raw-API negative** — non-member token → read backup manifest / list chunks / list restore_requests / create restore_request / write chunk = **all DENIED (403/400)**; original couple/messages/self-grant negative **all 403** (no regression). **Verified the cross-user restore mechanism** (would-be bug, checked before filing): the couple manifest points to ONE uploader-scoped snapshot, but the partner reads it via the **tokenized `?token=` capability URL** (plain GET → **200 + `enc:v1:`** confirmed) stored in the couple-gated manifest — sound, not a bug. **Also discovered the R24 `storage.rules` deploy gap is RESOLVED** (snapshot uploaded + compaction ran). **Pass E (notifications):** cold-start smoke 6/6 both ✓; the NEW `restore_requested` "Help your partner restore 💜" partner push is **DEPLOYED + live-firing** — Sam's `notification_queue` has **3 entries today** (from this session's live restore loops), routes to RESTORE_CONSENT (client wiring + consent screen live-verified). **DEPLOY GAP FOUND (not a bug): the R24-b functions change is NOT deployed**`onRestoreFulfilled` absent from the deployed list, and the couple doc has **no `lastRestoreSelfAlertAt`** + **0 `restore_self_alert`** entries → the deployed `onRestoreRequested` is the pre-R24-b (partner-push-only) version; the recipient self-alert + completion alert need `firebase deploy --only functions` (source correct + unit-tested). **Pass M (settings):** the new Security entries were all live-verified earlier this session (recovery-phrase reveal on no-lock, Copy button + `IS_SENSITIVE` masking, "Help my partner restore" + back button). **Cornerstone regression (Sam 5556, intact):** A — free Sam → premium Desire Sync → **Paywall** ("Go deeper together", pills legible, C-PW-001 holds); B — Play hub all cards render with correct premium badges; L — Messages inbox + main thread **fully decrypted** (attribution, Seen, day-separators, E2E lock glyphs, **0 `enc:` leak**, 0 FATAL); N — Today daily-question renders with decrypted answer + reveal state. Home renders. **0 FATAL across the round.** **⚠️ FIXTURE DAMAGE I CAUSED (process landmine — now documented in ClaudeQAPlan + Eng Manual): `./gradlew :app:connectedDebugAndroidTest` UNINSTALLS the app-under-test on completion, which WIPED QA's (5554) app data (auth + keys + App-Check debug token) → QA is now at fresh onboarding. Never run instrumented tests on the 5554/5556 fixtures — use a throwaway (5558).** Silver lining: confirmed **onboarding slide-1 CtaSlide art renders** on the fresh install (O-ONBOARD-001 stays fixed). **Fixture recovery is user-gated** — QA's account (`qa_1782321603516@closertest.com`) still exists server-side but I don't have the password and resetting it is a gated auth write; needs the password or authorization to reset. **Board:** 0 open P0/P1 · 1 P2 (O-AGE-001 pre-ship age gate, user) · 1 P3 (BRAND-DARK-COVERAGE, user). **Two user actions to fully close the round: (1) `firebase deploy --only functions` (self-alert + completion alert), (2) restore the QA fixture (password or re-auth).** All R24/R25 code uncommitted (user commits).
- **R25-b (2026-06-30) — QA(5554) fixture RECOVERED + live 2-device partner-assisted restore verified end-to-end; the R25 fixture-recovery gate is now CLOSED. 0 defects.** Reset QA's Auth password via admin (user-authorized) → signed QA back in → landed **NEEDS_RECOVERY** (data-wiped, no local couple key). Ran the full restore: QA "Start restore" published `restore_requests/{QA}` (code 592847) → **deployed `onRestoreRequested` fired end-to-end** (Firestore→FCM→Sam received "Help your partner restore 💜", notif id 40038, channel `partner_activity`, importance 4 — **LIVE Pass E restore-notif coverage, previously only via cold-start smoke**) → Sam's **Change-1 consent screen live-verified**: email anchor `qa_…@closertest.com` prominent + name **QA** (decrypted locally via couple key — server cannot) + "make sure this is your partner's real account before approving" + active confirm checkbox; **Approve disabled until code(6) AND confirmed** (verified both gates: code alone kept it disabled) → Sam approved (keybox sealed to QA's ECIES pubkey, request→READY, no error) → **QA auto-restored**: paired Home ("Connected with Sam" + "Sam / Revealed" decrypted-answer chip), **full chat history decrypts** (June 27→Today, all bodies readable), and a fresh send `restore_ok_R25` from restored QA **decrypts on Sam at 10:15 PM** = bidirectional round-trip proving the restored AES-256-GCM keyset is identical = **full R24 restore regression on the current build**. **Observation (deferred, low-pri, NOT filed as a defect):** tapping the restore push while Sam's app was already foregrounded (warm) opened Play hub, not RESTORE_CONSENT — most likely an artifact of tapping a *collapsed 2-item* notif group header (its contentIntent ≠ the restore notif's); couldn't cleanly re-test without re-wiping a precious fixture, and cold-start routing is smoke-green. **Still user-gated (unchanged):** `firebase deploy --only functions` for the self-alert + `onRestoreFulfilled` completion/anti-takeover pushes (blocked→deploy; source correct + unit-tested).
- **R25-c (2026-06-30) — LIVE-FIRE of the deployed owner-alerts (Change 3): both new restore self-alerts observed firing on QA's OWN device end-to-end; last user-gated item CLOSED. 0 defects.** User authorized re-wiping QA(5554) to watch the self-alerts live now that functions are deployed. `pm clear closer.app` → fresh onboarding (Allow notifications) → sign in → **NEEDS_RECOVERY** → "Start restore" published a fresh `restore_requests/{QA}` (code 565429). **(1) Request-time self-alert fired LIVE on QA's own device** — posted to QA's **system shade** (`closer.app` id 67945, channel `partner_activity`, importance 4, category social, tap contentIntent present) **and** wrote a durable in-app record (`users/{QA}/notification_queue`, `type=restore_self_alert`, 23:17:38): *"New device is restoring your history / If this wasn't you, secure your account now."* — plus the partner push to Sam (*"Help your partner restore 💜"*), both from the single `onRestoreRequested` create-trigger. **The R25-b notification-routing deferred obs is now CLOSED:** tapping Sam's *single* restore notif opened **RESTORE_CONSENT** (not Play hub) → confirms the earlier Play-hub artifact was tapping a *collapsed 2-item notif-group header* whose contentIntent ≠ the restore notif's. **Consent gate re-verified:** code(6) *alone* kept **Approve disabled** → checking "I reached QA directly…" **enabled** it (both gates required). Sam approved → **`onRestoreFulfilled` fired server-side (status ok, 1319 ms) on the REQUESTED→READY transition** → **(2) completion self-alert** durably queued to QA (`type=restore_self_alert`, 23:19:50): *"Your history was just restored / A new device now has access. If this wasn't you, secure your account now."* It did **not** post to QA's shade only because QA's app was **foregrounded** at that instant (it had auto-restored and navigated to Home) — expected foreground-FCM handling; the push still succeeded to QA's live tokens + the durable queue entry guarantees the owner sees it on any device. Both self-alerts landed **132 s apart** (> ~60 s dedupe window → no suppression, correct). **Robustness proven live:** one **stale FCM token** (`f_T4C0ri…`, `registration-token-not-registered`, left over from QA's pre-wipe install) failed the send but `Promise.allSettled` shrugged it off — the function finished `ok` and delivered to the other tokens. **QA auto-restored to paired Home** ("Connected with Sam" + "Sam / Revealed" decrypted chip), content decrypts, **0 FATAL/ANR** → fixture **left healthy** (no re-restore needed). **Net: Change 3 (owner alerts) is now fully live-validated end-to-end (request self-alert shade+queue, completion self-alert queue, READY-transition trigger, allSettled resilience) — the `firebase deploy --only functions` gate is CLOSED.** **Minor follow-on (not a defect):** prune tokens FCM reports as `not-registered` (stale-token housekeeping) — fits the existing scheduled-cleanup follow-on family.
- **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes the R25-c follow-on): shared helper + wired into ALL 19 push sites; build + 47 unit tests green. Requires user `firebase deploy --only functions` to go live.** New [`functions/src/notifications/pruneTokens.ts`](functions/src/notifications/pruneTokens.ts): `isDeadTokenError` prunes **only** `messaging/registration-token-not-registered` + `messaging/invalid-registration-token` — deliberately **NOT** `invalid-argument` or any transient/server error (`unavailable`/`internal`/quota/auth), so a payload bug or an outage can never wipe every user's tokens; `selectDeadTokens` (pure index→token map); `pruneDeadTokens` (best-effort, **never throws** — housekeeping must not fail the notify path; only touches Firestore when a dead token is actually seen; batch-deletes matching `fcmTokens` docs + clears the legacy `fcmToken` field). Each caller reuses the `tokens` array + `Promise.allSettled` results it already had → **one added line per site**. Wired into all 19 senders: questions (onAnswerWritten/onAnswerRevealed/onMessageWritten), dates (createDateMatch/onDateHistoryCreated/onDateReflectionWritten), couples (onCoupleLeave/acceptInviteCallable/scheduledOutcomesReminder), games (onGameSessionUpdate), notifications (gameRetention/dailyQuestionReminder/reengagement/streakReminder/sendGentleReminderCallable/sendThinkingOfYouCallable), billing (onEntitlementChanged), users (onUserDelete), backup (onRestoreRequested — the R25-c live repro that surfaced the stale `f_T4C0ri…` token). New [`pruneTokens.test.ts`](functions/src/notifications/pruneTokens.test.ts): **9 unit tests** (fn suite **38→47**) asserting dead-vs-transient classification, both error shapes (`errorInfo.code` + `code`), index mapping, dedupe, and fail-safe on garbage. `npm run build` clean (tsc verified every `db`/uid/`tokens`/results reference across the 19 sites) + `dist/notifications/pruneTokens.js` emitted. **Not deployed** (deploy is user-gated); pruning takes effect on the next functions deploy. (Note: the repo's own auto-commit daemon committed each file as `koga.industries@gmail.com` — I ran no git commit.)
- **R24-d (2026-06-30) — three restore-flow UX fixes per user reports ("there needs to be a back button on help your partner restore. also tapping recovery phrase does nothing" → then "add a copy button for the recovery phrase"). All 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; on a device with **no enrolled lock**, tapping "Recovery phrase" launched `BiometricPrompt` which silently errored (only `onAuthenticationSucceeded` was overridden) → felt like a dead tap; and once revealed there was no way to copy the phrase (12 words, error-prone to hand-transcribe). **What (uncommitted):** **(1) Back button** — `RestoreScreens.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 reveal** — `SecurityScreen.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). **(3) Copy button** — the Recovery-phrase `AlertDialog` gained a `dismissButton` "Copy" (with `CloserGlyphs.Copy` icon) that writes the phrase to the clipboard and toasts "Recovery phrase copied". The `ClipData` is flagged **`IS_SENSITIVE`** (via `description.extras` PersistableBundle) so the phrase is **redacted from the Android 13+ clipboard-preview chip and excluded from clipboard history** — copy convenience without leaking the secret into the preview UI. **LIVE-verified:** ✅ QA (no PIN) — tapping Recovery phrase immediately shows the dialog with the 12-word phrase ("lion fair card like foot good full fame disk flat"); ✅ **Copy** → toast fires and the clipboard-preview chip shows masked dots `••••••` (sensitive flag confirmed working); ✅ Sam — Settings → Security → "Help my partner restore" opens the consent screen **with a back arrow** + the empty-state card ("There's no restore request waiting right now…"), 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 A**`createRestoreRequest`'s `set()` over an existing request is a rule-denied key-changing update → a **retry silently `PERMISSION_DENIED`s**; fixed by delete-then-create. **Bug B**`expiresAt` 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 B**`BackupManager` (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 E**`onRestoreRequested` 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 phrase**`couple_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' `backupNow`→`compact` 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 background** — `MainActivity` 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 NULL`**host 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 sweep**`prompt → 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_DENIED` → `quit-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 test**`app/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) FIXED**`WheelSessionViewModel.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** "<Name> 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 session`→`Finish 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 `CtaSlide``FATAL EXCEPTION: java.lang.IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported` at `PainterResources…loadVectorResource``OnboardingScreen.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 `primaryMessage``BrandMessageRotator` 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** ("<partner> started <Game>" + 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 | **0 open** (O-AGE-001 18+ gate IMPLEMENTED+verified R28; birthDate persistence pending a gated rules deploy) | **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). _(R28 update: **O-AGE-001 18+ gate now IMPLEMENTED + live-verified; rules DEPLOYED** (birthDate persistence confirmed against the live rule) — the only remaining item is the Play maturity questionnaire (product, not code). **BRAND-DARK-COVERAGE is effectively resolved** — all 22 `illustration_*` have `drawable-night-nodpi/` dark variants; only a few transparent celebration assets lack a dark variant and they already read correctly on dark.)_
_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 → FIXED****theme-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_DENIED``ThisOrThatViewModel: 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). | **R28: IMPLEMENTED + live-verified; rules DEPLOYED** — 18+ DOB gate at sign-up (under-age blocked, no account) + conditional DOB step for Google/legacy; `AgeGate` + `User.birthDate` + rules allowlist + 8 unit tests. birthDate persistence verified against the deployed rule (authed update PATCH → 200; junk field → 403). **Remaining (product, not code): complete the Play content/maturity questionnaire to match actual content.** |
| 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.
## Notification deep-link routing — SINGLE mechanism (do NOT reintroduce a second one)
**Invariant:** an app-posted notification carries the resolved route in **one** place — the `app_route` **extra**
and routing is `MainActivity.deepLinkRouteFromIntent``pendingDeepLink``AppNavigation` `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 `CloserTheme``BrandIllustration` 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 `077a408`→`5868d06`). **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.)_