diff --git a/ClaudeReport.md b/ClaudeReport.md index 3170400c..dab637be 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,7 +18,8 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) -- **R24-b (2026-06-30) — hardened partner-assisted restore per user's "check the account matches the email" concern: recipient identity + active confirm gate on consent, two request-lifecycle bug fixes, and owner security alerts. 243 unit tests green; consent identity + confirm gate + re-request fix + full loop LIVE-verified on QA/Sam.** **Why:** when the partner approves, they hand over the couple key — the consent screen never showed *who* was receiving it, and the recipient's own account got no alert. Investigating surfaced two real robustness bugs too. **What (uncommitted):** **(1) Consent identity + confirm gate** (`RestoreViewModel`/`RestoreScreens`) — Sam now sees the recipient's **email (plaintext anchor) + display name (decrypted locally via the couple key; the server can't, so identity is necessarily client-side)**, plus an explicit **"I reached them directly and this is their account"** checkbox; **Approve is gated on `code.length==6 && confirmed`**. Added distinct **no-request / expired** empty states (the screen previously showed the form unconditionally on a stale deep-link). **(2) Two lifecycle bugs found + fixed** (`RestoreManager`): **Bug A** — `createRestoreRequest`'s `set()` over an existing request is a rule-denied key-changing update → a **retry silently `PERMISSION_DENIED`s**; fixed by delete-then-create. **Bug B** — `expiresAt` was never enforced; `fulfillRestore` now **rejects an expired request** before wrapping the key. **(3) Owner security alerts** (`onRestoreRequested` + new `onRestoreFulfilled`) — a **"was this you?" alert to the recipient's OWN devices** at request time and on key transfer (the real anti-takeover signal for a phished password when the owner still has a device); quiet-hours bypassed (security), deduped via `couples/{id}.lastRestoreSelfAlertAt` (60s) against create-loops, each notify branch independently try/caught; client `RESTORE_SELF_ALERT` type wired (`isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`). **Honest framing (SECURITY.md):** the code is the crypto root of trust; identity+confirm stop *accidental* approval + add social-engineering friction (they do NOT stop takeover — the couple email matches); the owner self-alert is the takeover catch; the strongest control (email-verification challenge) needs mail infra → Future.md. **Verification:** `:app:assembleDebug` + **243 unit tests** (+10: `RestoreViewModelTest` ×6 — identity/fallback/expired/no-request/approve-gating; `RestoreManagerTest` ×4 — delete-then-create order, expiry + wrong-code rejection, no-expiry-legacy allowed; functions `onRestoreRequested.test.ts` ×6 — dedupe window + READY-transition guard as extracted pure helpers) + functions **build green** + `wiring-scan` **🔴0 dead setters / 0 dead notif settings**. **LIVE on QA(5554)/Sam(5556), fresh APK:** ✅ Sam's consent shows QA's real **email `qa_1782321603516@closertest.com` + name "QA"** + the confirm checkbox using the name; ✅ **Approve stays disabled with code-only**, enables only after checking the box; ✅ **re-request after a prior request created a fresh code (370690) with NO `PERMISSION_DENIED`** (Bug A fix); ✅ approve → **QA restored key (couple_crypto recreated) + 34 messages with no phrase** and returned Home (R24 regression through the new gate). **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`, `data/backup/RestoreManager.kt`, `notifications/PartnerNotificationManager.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `RestoreViewModelTest.kt`, `RestoreManagerTest.kt`, `onRestoreRequested.test.ts`, + docs (SECURITY.md, Engineering_Reference_Manual R24-b, Future.md, ClaudeiOSPlan, this entry). +- **R24-d (2026-06-30) — two restore-flow UX fixes per user report ("there needs to be a back button on help your partner restore. also tapping recovery phrase does nothing"). Both LIVE-verified on QA/Sam; 244 unit tests green (no regressions).** **Why:** the manual "Help my partner restore" consent screen (R24-c) opened with no way back except gesture, and on an emulator/device with **no enrolled lock**, tapping "Recovery phrase" launched `BiometricPrompt` which silently errored (only `onAuthenticationSucceeded` was overridden) → felt like a dead tap. **What (uncommitted):** **(1) Back button** — `RestoreScreens.RestoreScaffold` gained an optional `onBack` param rendering a `CloserGlyphs.Back` `IconButton`; `RestoreRequestScreen`/`RestoreConsentScreen` accept `onBack`, wired in `AppNavigation` to `navigateBackOrHome` (`popBackStack()` else Home). **(2) Lock-less reveal** — `SecurityScreen.launchBiometricForPhrase` now checks `BiometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) != BIOMETRIC_SUCCESS` (or null activity) and, when there's no lock to prompt, reveals the phrase directly (`viewModel.revealRecoveryPhrase()`) instead of firing a prompt that no-ops. When a lock *is* enrolled, behaviour is unchanged (still gated). **LIVE-verified:** ✅ QA (no PIN) — tapping Recovery phrase now immediately shows the dialog with the 12-word phrase ("lion fair card like foot good full fame disk flat"); ✅ Sam — Settings → Security → "Help my partner restore" opens the consent screen **with a back arrow**, and the empty-state card renders ("There's no restore request waiting right now…" — no active QA request), and tapping back returns to Security. **Verification:** `:app:assembleDebug` OK + `:app:testDebugUnitTest` **244 green** (UI-only change, no test delta). Uncommitted: `ui/pairing/RestoreScreens.kt`, `ui/settings/SecurityScreen.kt`, `core/navigation/AppNavigation.kt`. +- **R24-b (2026-06-30) — hardened partner-assisted restore per user's "check the account matches the email" concern: recipient identity + active confirm gate on consent, two request-lifecycle bug fixes, and owner security alerts. 243 unit tests green; consent identity + confirm gate + re-request fix + full loop LIVE-verified on QA/Sam.** **Why:** when the partner approves, they hand over the couple key — the consent screen never showed *who* was receiving it, and the recipient's own account got no alert. Investigating surfaced two real robustness bugs too. **What (uncommitted):** **(1) Consent identity + confirm gate** (`RestoreViewModel`/`RestoreScreens`) — Sam now sees the recipient's **email (plaintext anchor) + display name (decrypted locally via the couple key; the server can't, so identity is necessarily client-side)**, plus an explicit **"I reached them directly and this is their account"** checkbox; **Approve is gated on `code.length==6 && confirmed`**. Added distinct **no-request / expired** empty states (the screen previously showed the form unconditionally on a stale deep-link). **(2) Two lifecycle bugs found + fixed** (`RestoreManager`): **Bug A** — `createRestoreRequest`'s `set()` over an existing request is a rule-denied key-changing update → a **retry silently `PERMISSION_DENIED`s**; fixed by delete-then-create. **Bug B** — `expiresAt` was never enforced; `fulfillRestore` now **rejects an expired request** before wrapping the key. **(3) Owner security alerts** (`onRestoreRequested` + new `onRestoreFulfilled`) — a **"was this you?" alert to the recipient's OWN devices** at request time and on key transfer (the real anti-takeover signal for a phished password when the owner still has a device); quiet-hours bypassed (security), deduped via `couples/{id}.lastRestoreSelfAlertAt` (60s) against create-loops, each notify branch independently try/caught; client `RESTORE_SELF_ALERT` type wired (`isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`). **Honest framing (SECURITY.md):** the code is the crypto root of trust; identity+confirm stop *accidental* approval + add social-engineering friction (they do NOT stop takeover — the couple email matches); the owner self-alert is the takeover catch; the strongest control (email-verification challenge) needs mail infra → Future.md. **Verification:** `:app:assembleDebug` + **243 unit tests** (+10: `RestoreViewModelTest` ×6 — identity/fallback/expired/no-request/approve-gating; `RestoreManagerTest` ×4 — delete-then-create order, expiry + wrong-code rejection, no-expiry-legacy allowed; functions `onRestoreRequested.test.ts` ×6 — dedupe window + READY-transition guard as extracted pure helpers) + functions **build green** + `wiring-scan` **🔴0 dead setters / 0 dead notif settings**. **LIVE on QA(5554)/Sam(5556), fresh APK:** ✅ Sam's consent shows QA's real **email `qa_1782321603516@closertest.com` + name "QA"** + the confirm checkbox using the name; ✅ **Approve stays disabled with code-only**, enables only after checking the box; ✅ **re-request after a prior request created a fresh code (370690) with NO `PERMISSION_DENIED`** (Bug A fix); ✅ approve → **QA restored key (couple_crypto recreated) + 34 messages with no phrase** and returned Home (R24 regression through the new gate). **FOUND + FIXED — misleading Recovery-phrase empty-state on a partner-restored device (user-asked "ensure Recovery Phrase displays as it should").** After partner-assist restore, `storeTransferredKeyset` intentionally transfers the key **but not the phrase** (the phrase is one-way from the couple key), so QA's Settings → Security correctly greyed the "Recovery phrase" row — but the copy read *"…will appear here once your couple is set up on this device,"* which is **false** (QA is fully set up, chat working). Fixed `SecurityScreen`/`SecurityViewModel` to detect **key-present-but-no-phrase** (`encryptionManager.aeadFor(coupleId) != null` with `recoveryPhrase == null`) and show the accurate partner-restore copy ("You set this device up with your partner's help, so the recovery phrase isn't saved here. Ask your partner to open Settings → Security and read you theirs — either partner's phrase can restore your history."). **LIVE-verified both devices:** ✅ **Sam (original device)** — Recovery phrase row enabled → PIN-gated reveal shows the correct **12-word phrase** ("lion fair card like foot good full fame disk flat") with the right explanatory copy; ✅ **QA (partner-restored)** — greyed row now shows the accurate partner-restore message (not the "not set up" copy). **Navigation (answered):** recipient reaches restore via the `NEEDS_RECOVERY` "Unlock your history" screen → **"No phrase? Ask your partner to restore this device"** → RestoreRequestScreen; the helper reaches consent **only via the `restore_requested` notification** (OS push + in-app inbox entry once the function is deployed) → RESTORE_CONSENT — no manual Settings entry exists (candidate follow-on). **BUILT (R24-c, user-asked "we will build that entry backup") — partner-assist now also restores the recovery phrase + a manual helper entry.** After confirming a partner-restored device was fully restored (key + content) but deliberately *phrase-less*, added: **(1) phrase transfer** — the `keybox` ECIES payload became an envelope `ckx:v1:{keyset, phrase?}` (`CoupleKeyTransfer.wrapCoupleKey(...recoveryPhrase)` → `unwrapCoupleKey` returns `TransferredKey(keyset, phrase?)` → `CoupleEncryptionManager.storeTransferredKeyset(coupleId, handle, phrase?)`); `RestoreManager.fulfillRestore` includes the sender's phrase, `completeRestore` stores it. Backward-compatible (a prefix-less legacy keybox → key-only). The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — server stays blind; both partners are meant to hold the shared phrase, so this restores parity, not exposure. **(2) Manual "Help my partner restore"** row in Settings → Security (gated on holding the couple key) → RESTORE_CONSENT, so the approver can reach consent even if the notification never arrives. **LIVE-verified QA/Sam:** ✅ full re-run (QA wiped → requested code 462926 → **Sam approved via the new manual Settings entry**, not a notification) restored key + 34 msgs; ✅ QA's Settings → Security **Recovery phrase row now enabled**, and PIN-gated reveal shows the **identical phrase** as Sam ("lion fair card like foot good full fame disk flat") — the greyed/partner-restore state is gone; ✅ `couple_crypto_secure.xml` grew 1646→1872 B (phrase now stored). Unit: `CoupleKeyTransferTest` +1 (phrase round-trip) + return-type update → **244 total green**. **GATED (user deploys):** `onRestoreRequested`+`onRestoreFulfilled` (self-alert pushes) + still-pending `storage.rules` (snapshot compaction) + R23 date functions. All uncommitted (user commits): `ui/pairing/{RestoreViewModel,RestoreScreens}.kt`, `ui/settings/SecurityScreen.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/backup/RestoreManager.kt`, `notifications/PartnerNotificationManager.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `{RestoreViewModel,RestoreManager,CoupleKeyTransfer}Test.kt`, `onRestoreRequested.test.ts`, + docs (SECURITY.md, Engineering_Reference_Manual R24-b/c, Future.md, ClaudeiOSPlan, this entry). - **R24 (2026-06-30) — built E2EE conversation backup + full partner-assisted restore (foundation for Option B). Code-complete, 233 unit tests green, deploy-gated for live verify.** **What (uncommitted):** devices keep a couple-key-encrypted backup of all conversations so a new/wiped device restores history, and a partner can **fully restore for the other (key + content) with no recovery phrase**. **Phase A** — schema (`FirestoreCollections` backup/restore consts) + `BackupCodec` (JSON→`enc:v1:` envelope, checksum, reactions) + `FirestoreBackupDataSource` (manifest `generation`-CAS transactions, chunk append, snapshot finalize w/ delete-after-commit, restore_requests) + `firestore.rules` (backup manifest/chunks + restore_requests, new `isPublicKey` helper) + `storage.rules` (`users/{uid}/backups/` — uploader-scoped, reuses existing `onUserDelete` cleanup). **Phase B** — `BackupManager` (incremental `appendSince` + full-state `compact` that captures deletes/reactions; resolved-timestamp-only; throttled/single-flighted) + conversation backup-reads on `FirestoreConversationDataSource`; opportunistic trigger from `HomeViewModel.loadHome`. **Phase C** — separate `ConversationCacheDatabase` (Room; kept OFF the asset-backed `AppDatabase` to protect its schema hash) + `BackupRestoreManager` (download snapshot+chunks → decrypt → checksum-validate → upsert dedup-by-id). **Phase D (headline)** — `CoupleKeyTransfer` (ECIES-wrap the couple keyset to a recovering device's fresh pubkey; context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; **6-digit OOB code = SHA-256(pubkey‖nonce)**) + `RestoreManager` (A request→complete, B fulfil-after-code) + `RestoreViewModel`/`RestoreRequestScreen`/`RestoreConsentScreen` + RecoveryScreen "Ask your partner to restore" entry + nav. **Phase E** — `onRestoreRequested` Cloud Function (notifies partner; high-signal, not toggle-gated; audit-log no key material) + `RESTORE_REQUESTED` notif type wired (`fromRemoteType`/`routeFor`/isEnabled). **Verification:** `:app:assembleDebug` + **233 unit tests** (9 new: `CoupleKeyTransferTest` proves the couple key round-trips only to the right device+context, wrong key/nonce fail, OOB code binds to pubkey+nonce for swap-detection; `BackupCodecTest` round-trip/checksum/forward-compat/cursor) + functions **tsc green** + `wiring-scan` **🔴0**. **Security posture:** server holds only `enc:v1:` ciphertext + `keybox:v1:` blobs (AEAD tamper-evident); the OOB-code entry gate defeats a server/MITM pubkey swap + remote account-takeover; unpair revokes (rules require active couple); threat model + residual risks (no forward secrecy, rollback-freshness, client-enforced verification) documented in SECURITY.md. **Docs:** SECURITY.md (E2EE list + 2 threat rows + roadmap), Eng Ref Manual (**R24-BACKUP** landmine w/ wire formats + hazards), ClaudeQAPlan (Pass D backup/restore + rules-negative + Pass E `restore_requested`), ClaudeiOSPlan (byte-compat parity item), Future.md (Option B + FS + freshness + WorkManager/backfill + Settings indicator/opt-out). **LIVE-VERIFIED (2026-06-30) on QA(5554)/Sam(5556), fresh APK, `-gpu swiftshader_indirect`:** simulated device loss on QA by deleting **only** the secure prefs (`couple_crypto_secure.xml` = couple key + `user_key_secure.xml` = ECIES key) via `run-as` (NO `pm clear` — App Check token preserved) + backgrounding (KEYCODE_HOME) + `am kill` to drop the in-memory key → cold-launch QA lands on `NEEDS_RECOVERY`. **Full partner-assist (no phrase):** ✅ QA "Ask your partner to restore this device" → RestoreRequestScreen "Start restore" → published a fresh `pub:v1:` + created `restore_requests/{QA}` (rules **allowed**, no `PERMISSION_DENIED`) → showed OOB code **940687**. ✅ Sam driven to RESTORE_CONSENT (deep-link, function-independent) → typed **940687** → "Approve restore" → `fulfillRestore` verified code == `SHA-256(pubkey‖nonce)`, wrapped the couple keyset to QA's fresh pubkey (`keybox:v1:`), set status READY. ✅ **QA auto-completed with NO recovery phrase** — `couple_crypto_secure.xml` **recreated at 20:00** via `unwrapCoupleKey`+`storeTransferredKeyset`; QA navigated past `NEEDS_RECOVERY` to Home ("Connected with Sam"). ✅ **Content restored: 34 messages** across all 4 conversations (`main`:28 + 3 `q_daily_fun_*` discuss threads) upserted into QA's separate `conversation_cache.db` (confirmed via on-device `sqlite3` count) — sourced from the **Firestore `enc:v1:` chunks** (server-blind). ✅ Restored key **decrypts live content**: QA Messages inbox + main thread render the full history in plaintext ("hi"/"hey", R18 test msgs, "gu", today's pings) — not 🔒 placeholders. ✅ `completeRestore` deleted the request post-unwrap (no lingering keybox). **DEPLOY GAP FOUND (not a code bug): `storage.rules` was NOT deployed** (only `firestore.rules` was — which is why every Firestore-side op above worked). Both devices' `backupNow`→`compact` snapshot upload 403s (`GetDownloadUrlTask` "Permission denied") because the deployed storage.rules lacks the `users/{uid}/backups/` block → snapshot compaction can't run. **Backup + restore still work fully via the Firestore chunks** (the 34-msg restore proves it); only the Storage snapshot/compaction (chunk-folding) is blocked. **Fix = deploy `firebase deploy --only storage`** (rules file is correct + structurally identical to the working `chat_media` block); after deploy, compaction folds chunks → snapshot automatically on the next `backupNow`. **Verdict: R24 — headline full partner-assisted restore (key + 34 msgs, NO phrase, OOB-code-gated) VERIFIED LIVE end-to-end + server-blind; 0 FATAL. One outstanding DEPLOY-gate: user runs `firebase deploy --only storage` to enable snapshot compaction (+ still-pending `onRestoreRequested` for the partner push, and the R23 date functions). Board unchanged: 0 open P0/P1.** All uncommitted (user commits): `data/backup/*`, `data/remote/{FirestoreBackupDataSource,BackupCodec,FirebaseStorageDataSource,FirestoreConversationDataSource,FirestoreCollections}.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/local/ConversationCache*`, `di/DatabaseModule.kt`, `domain/model/ConversationBackup.kt`, `ui/pairing/{RestoreViewModel,RestoreScreens,RecoveryScreen}.kt`, `ui/home/HomeViewModel.kt`, `notifications/PartnerNotificationManager.kt`, `core/navigation/{AppRoute,AppNavigation}.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `firestore.rules`, `storage.rules`, `app/build.gradle.kts`, tests, + the docs above. - **R23 (2026-06-30) — built Date Memories & Replay (the date-roadmap killer feature) end-to-end, then LIVE-verified the whole loop on QA↔Sam. 0 defects in the new feature, 0 FATAL.** **Work (uncommitted):** the private→reveal loop applied to real dates — mark a mutual match **"We did this"** → `couples/{c}/date_history/{matchId}` (PLAINTEXT coarse metadata, idempotent doc-id=matchId merge) → **`date_reflections/{dateId}/answers/{uid}` (+ read-gated `secure/payload`) E2EE**, mirroring the daily-question couple-key gated reveal exactly. New: `DateMemory`/`DateReflection` models, `FirestoreDateMemoryDataSource`/`FirestoreDateReflectionDataSource`, `DateReflectionScreen`+VM (EDIT→AWAITING→REVEALED), `DateMemoriesScreen`+VM (Replay timeline), `DateMatchesScreen` "We did this" + "Your date memories" entry, **Phase C Home nudge** (`HomePriorityEngine.DATE_REFLECTION_PENDING` value-action + `HomeViewModel` pending computation + `HomeActionTarget.DateMemories` + `glyph_date_replay`), 2 Cloud Functions (`onDateReflectionWritten`/`onDateHistoryCreated`), partner-sheet emoji→brand-glyph retrofit, `firestore.rules` (`date_history`+`date_reflections`, **DEPLOYED by user**), docs (SECURITY/iOS-parity/QA-plan Pass E+N). **Cheap gates GREEN:** `:app:compileDebugKotlin` + `assembleDebug` (128MB APK) + `HomePriorityEngineTest` **25/25** (2 new DATE_REFLECTION_PENDING cases) + functions `tsc`; `wiring-scan` **🔴0 dead setters / 0 dead notif settings** for the date feature. **LIVE on QA(5554)/Sam(5556), fresh APK, software-GL:** ✅ mark-done → `date_history` synced to **both** timelines · ✅ QA reflects → AWAITING ("Saved privately 💜 — waiting for Sam"), **privacy gate holds** (QA can't see Sam pre-both) · ✅ Sam reflects → **mutual reveal side-by-side**, QA screen **live-flipped with no refresh** (observeReflected), **bidirectional E2EE decrypt** with correct You/partner labels · ✅ empty-state art (`illustration_date_memories_empty`) · ✅ timeline newest-first, per-row chips **Reflect/View** · ✅ **Home nudge** "Reflect on your date with Sam 💭" appears in BOTH the *Also waiting* (pending) and *More ways to connect* (secondary) surfaces with `glyph_date_replay`, routes to the timeline, clears after reflecting · ✅ **mark-done idempotency** (both partners tapped "We did this" on the same date → timeline shows it ONCE) · ✅ **light + dark** both date screens · ✅ cold-start no crash (nav restores back-stack) · ✅ partner-sheet retrofit renders all 5 actions as brand glyphs (no emoji-as-icons). **FOUND + FIXED — R23-DQ-001 (P2) daily-question silent re-answer data loss:** answering an already-answered daily Q logged `Write failed at …/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` — the **immutable-answer guard** (`secure allow update:false`) correctly rejecting an overwrite. Root cause: the daily-Q screen + Home sourced "answered?" from **local Room only**, so on a fresh device / cleared DB (Room empty while Firestore still holds the answer) Home showed a **stale "your turn"** and the screen offered an **editable re-answer** — and a *changed* pick would be silently dropped (the secure overwrite is denied; reveal keeps the old content). NOT caused by the date work (`git diff` = only `date_*` rule additions; daily-Q rules/datasource untouched). **Fix (uncommitted):** new **Room-first** `reconcileLocalAnswerFromFirestore` (`ui/questions/LocalAnswerMapping.kt`) — if Room lacks the answer but Firestore has it, rebuild from the read-gated couple-key payload (owner can always read their own), map option-texts, and write it back to Room; persists only when the payload actually decrypts (a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal, never a lossy re-answer) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Room-first ⇒ the normal answered path is byte-identical. **Covered by 5 new unit tests** (`ReconcileLocalAnswerTest`: Room-hit short-circuit, heal+persist+option-text-map, no-prior-answer→null, assignment-rotation guard, undecryptable→answered-but-not-persisted). Full suite **224 green**, APK builds. Live fresh-device repro is `pm clear`-gated (forbidden — App Check token) so verified by unit tests + the provably-equivalent `withLocalAnswer` render path. `QuestionDetailViewModel` (pack questions) audited — **local-only writes, no gated-secure path → not vulnerable** (no change). The **date feature is also immune** (its `hasReflected` reads Firestore, not Room). **GATED (user-only):** notification pushes (`date_reflection_partner`/`date_reflection_ready`/`date_logged`) need the 2 new functions **deployed** — code-complete + `tsc`-green, not yet live. **Verdict: R23 — Date Memories & Replay shipped + LIVE-verified flawless across the full loop + Home nudge + idempotency + light/dark; 0 defects in the feature, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Pending: user deploys the 2 date functions → verify the 3 date pushes live.** Uncommitted (user commits): the new date `*.kt` + models + datasources + screens, `HomePriorityEngine.kt`/`HomeViewModel.kt`/`HomeScreen.kt`, `DateMatchesScreen.kt`/`DateMatchesViewModel.kt`, `AppRoute.kt`/`AppNavigation.kt`, `FirestoreCollections.kt`, `PartnerNotificationManager.kt`/`AppMessagingService.kt`, `functions/src/dates/*` + `index.ts`, `firestore.rules`, `glyph_date_replay.xml` + `illustration_date_memories_empty.png` (+night), `HomePriorityEngineTest.kt`, `SECURITY.md`/`ClaudeiOSPlan.md`/`ClaudeQAPlan.md`/`ClaudeReport.md`. - **R22 (2026-06-29) — SECURITY.md recs #6 + #7 implemented, then full QA pass. 0 new defects, 0 FATAL.** **Work (uncommitted):** **#6 recovery-phrase save-confirmation at pairing** — the invite screen now makes the inviter **re-type one random word of the phrase** ("type word #N") before pairing feels done → "✓ Saved — you're all set." (`CreateInviteScreen.kt`). ✓ verified live on a fresh account (5558): renders, correct word → confirmed, index randomizes per entry (#5 then #8). **#7 biometric app-lock re-arms on background** — `MainActivity` lifecycle observer drops the unlocked session after the app is backgrounded past a 60s grace (`BIOMETRIC_RELOCK_GRACE_MS`), so a picked-up open phone re-prompts (not only cold-start); grace avoids re-locking on quick task-switches. Code-complete + compiles; **live re-lock pending a physical device** (emulators have no enrolled biometric/PIN). SECURITY.md/Future.md updated (#6/#7 → done). **QA run:** cheap gates ALL GREEN — build + **210 unit + 24 functions**, theme-scan CRIT **0**, painter-xml **0**; baseline both **free**, **0 active sessions**, **0 FATAL** both. Smoke: **5556 6/6** (launcher + all 5 notif cold-starts open&stay); **5554 launcher PASS + 5 FCM-delivery BLOCKs** (environmental "flaky emulator FCM, rerun" — **0 FAIL**, no crashes; shared notif code path proven by 5556). **A cornerstone live** (free → Desire Sync → Paywall, warmed "Full answer history and growth" renders). **D** carries from R20 (no rules/crypto change); **B/E** + this session's copy/bubble/#6/reveal verified R21. **INFRA finding (not app):** the 2nd/3rd emulator crashed seconds after boot with `eglMakeCurrent failed` / `Draw context is NULL` — **host GPU/EGL context exhaustion** from running 3 emulators on the hardware GPU. Fixed by killing the spare (5558) + relaunching the QA/Sam pair with **`-gpu swiftshader_indirect`** (software GL) — stable since. Saved to memory (QA-ops). **Verdict: R22 — #6 shipped+verified, #7 code-complete (needs-device), build stable + cornerstones hold, 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Uncommitted (user commits): `MainActivity.kt`, `CreateInviteScreen.kt`, `SECURITY.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md` (+ the broader session work). **N-TODAY-001 (P3, FIXED) — Today reveal state was confusing (user-reported):** after answering + revealing, the Today tab still showed the **editable answer form + a prominent "Save privately"** (looked re-answerable) AND a card titled **"Answer revealed"** that showed only the user's *own* answer (the mutual reveal is actually behind the "View reveal" button). Fixed in `LocalQuestionContent.kt` (Today-only — sole caller `DailyQuestionScreen`): the answer form now hides once `submitted`, and the card is retitled **"Your answer"** with an accurate status line ("Saved privately — waiting for your partner" / "…ready to reveal together" / "Revealed together — open View reveal to compare"). Verified live (5554, revealed state): form gone, card reads "Your answer / Cozy / Revealed together — open View reveal to compare"; 0 FATAL. diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 954e1857..e4672af2 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1192,6 +1192,12 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`. couple key; the code is a fingerprint of the exact pubkey in the request doc (defeats a server/MITM pubkey swap + account-takeover). Never reduce it to a tap-to-approve. Consume (delete) the request after unwrap so no wrapped key lingers. +- **Keybox payload carries the phrase too (R24-c).** The ECIES plaintext is now an envelope `ckx:v1:{json}` with + `{keyset, phrase?}` — `wrapCoupleKey(..., recoveryPhrase)` includes the sender's phrase; `unwrapCoupleKey` + returns `TransferredKey(keyset, recoveryPhrase?)`; `storeTransferredKeyset(coupleId, handle, phrase?)` persists + the phrase so a partner-restored device can reveal it. **Backward-compatible:** a plaintext WITHOUT the + `ckx:v1:` prefix is a legacy keyset-only keybox → decodes to `(keyset, null)`. Don't remove the legacy branch. + The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — never log it (same rule as the key). - **Restore requires the couple key first** (phrase or partner keybox) — content decrypt is couple-key. Fail soft everywhere (missing key → skip backup / "restore unavailable", never crash; never log keys/plaintext/phrase). - **Re-request must delete before create (R24-b, Bug A).** `createRestoreRequest` does `.set()`; over an existing