Closer/ClaudeReport.md

137 KiB
Raw Blame History

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. Most fixed-and-pruned IDs above are documented in its Known landmines and recent fixes section — read before re-touching the affected area.

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

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

Run-state (current)

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

Severity board

Severity Open Fixed (pending 1 confirm)
P0 0 0
P1 0 0
P2 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 → FIXEDtheme-scan CRITICAL 9→0, build+units green; C-THEME-001/002 verified LIVE (dark). Confirmed + pruned R15 fixes N-001 (P1, live add/delete) + N-002 (P2, Home "Date coming up"). M-001 carried (not re-tested this round). Remaining open: 1 P2 pre-ship (O-AGE-001) + 3 P3 (2 brand backlogs + C-ORIENT-001). 4 C-THEME fixes (004/005/008/009) pending live confirm next round.

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

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

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

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

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

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

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

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

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

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

Round history (one line each)

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

Operational constants

  • Execution mode: autonomous run-to-completion — don't stop; fix blockers inline; cycle fix→re-QA until flawless. Don't hand back when context fills — re-read this run-state + coverage after any compaction. Commit before interruptible work; recover stuck sessions via the session-start ritual.
  • Standing authorization (user, 2026-06-24): may firebase deploy --only firestore:rules + has admin access (Firestore reads/writes/seeds + entitlement toggles) — run without pausing. Only the macOS requirement for iOS (Parts 2/3) is a hard stop.
  • Hardening backlog → Future.md: App Check not enforced on Firestore. (Correction R15: the users/{uid} update rule is NOT open — it enforces a field allowlist (firestore.rules ~L198, hasOnly([...])); R15 extended it for quietHours*+timezone. Keep that list in sync with FirestoreUserDataSource when adding a client-written field.)