From 1e9f8b97bc0bf1dcfb144f3860375a4406c29c6a Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 20:43:34 -0500 Subject: [PATCH] docs: update Future.md, ClaudeQAPlan.md, ClaudeReport.md, ClaudeiOSPlan.md, Engineering_Reference_Manual.md for R24 backup/restore --- ClaudeQAPlan.md | 17 ++++++++- ClaudeReport.md | 2 ++ ClaudeiOSPlan.md | 15 ++++++++ Future.md | 32 +++++++++++++++++ docs/Engineering_Reference_Manual.md | 54 ++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index 83c9ad4b..58ba6964 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -745,6 +745,19 @@ Account); Paywall; Your Progress/Activity; Recovery. (only `wrappedCoupleKey`/`encryptedRecoveryPhrase` ever transit — verify via admin read). Without this, "I got a new phone" silently loses the relationship history. (Also exercised from the account-lifecycle angle in Pass F and the Settings → Security flow in Pass M.) + - **CONVERSATION BACKUP + FULL PARTNER-ASSISTED RESTORE (R24) — server-blind + the OOB-code gate.** Send messages → + a backup accrues (`couples/{id}/backup/manifest` + `.../chunks/{seq}` — admin-read shows ONLY `enc:v1:` payloads; + snapshot blob at Storage `users/{uid}/backups/{id}` is ciphertext). **Self-restore:** on a device with the couple + key, "restore" repopulates the local cache; admin confirms the server held only ciphertext. **Full partner-assist + (no phrase) — the headline:** simulate device loss WITHOUT `pm clear` (clear only `couple_crypto_secure` + + `user_key_secure` + `conversation_cache.db` via `run-as`) → recipient A "Ask your partner to restore" → shows a + 6-digit code → partner B gets `restore_requested` push → B **types the code** → A's key + content restore, **never + entering the phrase**. Admin confirms only `keybox:v1:`/ciphertext on the server. **Negative (rules):** non-member + read of backup/restore docs **403**; partner writing a keybox to a non-partner request **403**; creating a + restore_request for another uid **403**; post-unpair fulfil **403**. **OOB-code binding:** a mismatched code is + **rejected** (B's device refuses to wrap); a swapped pubkey yields a different code. Files: `data/backup/*`, + `crypto/CoupleKeyTransfer.kt`, `data/remote/FirestoreBackupDataSource.kt`, `functions/src/backup/onRestoreRequested.ts`. + Unit coverage: `CoupleKeyTransferTest` + `BackupCodecTest`. See the Eng Ref Manual **R24-BACKUP** landmine. - **D5 App Check / Functions / secrets:** App Check enforced; callables validate auth+membership; webhook authenticity; admin-only writes rejected from clients; service-account JSONs never committed; no plaintext/secrets in logcat; temp files deleted. @@ -897,7 +910,9 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones. in-app/Together record**; tapping the push → Home, not a dead-end) · `date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection; "your turn" when one reflects, "ready to reveal" when both; gated `notifPartnerAnswered`+quiet hours) · - `date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) · `spki`(key identity/confirm → security/key screen) · + `date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) · + `restore_requested`(**onRestoreRequested** → partner → the restore-consent screen; high-signal help request, NOT + suppressed by the routine partner-activity toggle, only quiet hours) · `spki`(key identity/confirm → security/key screen) · `subscription_entitlement_changed` & `security_recovery` (if present). - **Game-notification suite (per game):** A starts from Play hub → B gets the start/join push (if supported) → B taps and lands on the correct join/waiting/active screen → B can join from there → A sees B joined/answered → both finish diff --git a/ClaudeReport.md b/ClaudeReport.md index 92d86fde..3170400c 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,6 +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 (2026-06-30) — built E2EE conversation backup + full partner-assisted restore (foundation for Option B). Code-complete, 233 unit tests green, deploy-gated for live verify.** **What (uncommitted):** devices keep a couple-key-encrypted backup of all conversations so a new/wiped device restores history, and a partner can **fully restore for the other (key + content) with no recovery phrase**. **Phase A** — schema (`FirestoreCollections` backup/restore consts) + `BackupCodec` (JSON→`enc:v1:` envelope, checksum, reactions) + `FirestoreBackupDataSource` (manifest `generation`-CAS transactions, chunk append, snapshot finalize w/ delete-after-commit, restore_requests) + `firestore.rules` (backup manifest/chunks + restore_requests, new `isPublicKey` helper) + `storage.rules` (`users/{uid}/backups/` — uploader-scoped, reuses existing `onUserDelete` cleanup). **Phase B** — `BackupManager` (incremental `appendSince` + full-state `compact` that captures deletes/reactions; resolved-timestamp-only; throttled/single-flighted) + conversation backup-reads on `FirestoreConversationDataSource`; opportunistic trigger from `HomeViewModel.loadHome`. **Phase C** — separate `ConversationCacheDatabase` (Room; kept OFF the asset-backed `AppDatabase` to protect its schema hash) + `BackupRestoreManager` (download snapshot+chunks → decrypt → checksum-validate → upsert dedup-by-id). **Phase D (headline)** — `CoupleKeyTransfer` (ECIES-wrap the couple keyset to a recovering device's fresh pubkey; context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; **6-digit OOB code = SHA-256(pubkey‖nonce)**) + `RestoreManager` (A request→complete, B fulfil-after-code) + `RestoreViewModel`/`RestoreRequestScreen`/`RestoreConsentScreen` + RecoveryScreen "Ask your partner to restore" entry + nav. **Phase E** — `onRestoreRequested` Cloud Function (notifies partner; high-signal, not toggle-gated; audit-log no key material) + `RESTORE_REQUESTED` notif type wired (`fromRemoteType`/`routeFor`/isEnabled). **Verification:** `:app:assembleDebug` + **233 unit tests** (9 new: `CoupleKeyTransferTest` proves the couple key round-trips only to the right device+context, wrong key/nonce fail, OOB code binds to pubkey+nonce for swap-detection; `BackupCodecTest` round-trip/checksum/forward-compat/cursor) + functions **tsc green** + `wiring-scan` **🔴0**. **Security posture:** server holds only `enc:v1:` ciphertext + `keybox:v1:` blobs (AEAD tamper-evident); the OOB-code entry gate defeats a server/MITM pubkey swap + remote account-takeover; unpair revokes (rules require active couple); threat model + residual risks (no forward secrecy, rollback-freshness, client-enforced verification) documented in SECURITY.md. **Docs:** SECURITY.md (E2EE list + 2 threat rows + roadmap), Eng Ref Manual (**R24-BACKUP** landmine w/ wire formats + hazards), ClaudeQAPlan (Pass D backup/restore + rules-negative + Pass E `restore_requested`), ClaudeiOSPlan (byte-compat parity item), Future.md (Option B + FS + freshness + WorkManager/backfill + Settings indicator/opt-out). **LIVE-VERIFIED (2026-06-30) on QA(5554)/Sam(5556), fresh APK, `-gpu swiftshader_indirect`:** simulated device loss on QA by deleting **only** the secure prefs (`couple_crypto_secure.xml` = couple key + `user_key_secure.xml` = ECIES key) via `run-as` (NO `pm clear` — App Check token preserved) + backgrounding (KEYCODE_HOME) + `am kill` to drop the in-memory key → cold-launch QA lands on `NEEDS_RECOVERY`. **Full partner-assist (no phrase):** ✅ QA "Ask your partner to restore this device" → RestoreRequestScreen "Start restore" → published a fresh `pub:v1:` + created `restore_requests/{QA}` (rules **allowed**, no `PERMISSION_DENIED`) → showed OOB code **940687**. ✅ Sam driven to RESTORE_CONSENT (deep-link, function-independent) → typed **940687** → "Approve restore" → `fulfillRestore` verified code == `SHA-256(pubkey‖nonce)`, wrapped the couple keyset to QA's fresh pubkey (`keybox:v1:`), set status READY. ✅ **QA auto-completed with NO recovery phrase** — `couple_crypto_secure.xml` **recreated at 20:00** via `unwrapCoupleKey`+`storeTransferredKeyset`; QA navigated past `NEEDS_RECOVERY` to Home ("Connected with Sam"). ✅ **Content restored: 34 messages** across all 4 conversations (`main`:28 + 3 `q_daily_fun_*` discuss threads) upserted into QA's separate `conversation_cache.db` (confirmed via on-device `sqlite3` count) — sourced from the **Firestore `enc:v1:` chunks** (server-blind). ✅ Restored key **decrypts live content**: QA Messages inbox + main thread render the full history in plaintext ("hi"/"hey", R18 test msgs, "gu", today's pings) — not 🔒 placeholders. ✅ `completeRestore` deleted the request post-unwrap (no lingering keybox). **DEPLOY GAP FOUND (not a code bug): `storage.rules` was NOT deployed** (only `firestore.rules` was — which is why every Firestore-side op above worked). Both devices' `backupNow`→`compact` snapshot upload 403s (`GetDownloadUrlTask` "Permission denied") because the deployed storage.rules lacks the `users/{uid}/backups/` block → snapshot compaction can't run. **Backup + restore still work fully via the Firestore chunks** (the 34-msg restore proves it); only the Storage snapshot/compaction (chunk-folding) is blocked. **Fix = deploy `firebase deploy --only storage`** (rules file is correct + structurally identical to the working `chat_media` block); after deploy, compaction folds chunks → snapshot automatically on the next `backupNow`. **Verdict: R24 — headline full partner-assisted restore (key + 34 msgs, NO phrase, OOB-code-gated) VERIFIED LIVE end-to-end + server-blind; 0 FATAL. One outstanding DEPLOY-gate: user runs `firebase deploy --only storage` to enable snapshot compaction (+ still-pending `onRestoreRequested` for the partner push, and the R23 date functions). Board unchanged: 0 open P0/P1.** All uncommitted (user commits): `data/backup/*`, `data/remote/{FirestoreBackupDataSource,BackupCodec,FirebaseStorageDataSource,FirestoreConversationDataSource,FirestoreCollections}.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/local/ConversationCache*`, `di/DatabaseModule.kt`, `domain/model/ConversationBackup.kt`, `ui/pairing/{RestoreViewModel,RestoreScreens,RecoveryScreen}.kt`, `ui/home/HomeViewModel.kt`, `notifications/PartnerNotificationManager.kt`, `core/navigation/{AppRoute,AppNavigation}.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `firestore.rules`, `storage.rules`, `app/build.gradle.kts`, tests, + the docs above. - **R23 (2026-06-30) — built Date Memories & Replay (the date-roadmap killer feature) end-to-end, then LIVE-verified the whole loop on QA↔Sam. 0 defects in the new feature, 0 FATAL.** **Work (uncommitted):** the private→reveal loop applied to real dates — mark a mutual match **"We did this"** → `couples/{c}/date_history/{matchId}` (PLAINTEXT coarse metadata, idempotent doc-id=matchId merge) → **`date_reflections/{dateId}/answers/{uid}` (+ read-gated `secure/payload`) E2EE**, mirroring the daily-question couple-key gated reveal exactly. New: `DateMemory`/`DateReflection` models, `FirestoreDateMemoryDataSource`/`FirestoreDateReflectionDataSource`, `DateReflectionScreen`+VM (EDIT→AWAITING→REVEALED), `DateMemoriesScreen`+VM (Replay timeline), `DateMatchesScreen` "We did this" + "Your date memories" entry, **Phase C Home nudge** (`HomePriorityEngine.DATE_REFLECTION_PENDING` value-action + `HomeViewModel` pending computation + `HomeActionTarget.DateMemories` + `glyph_date_replay`), 2 Cloud Functions (`onDateReflectionWritten`/`onDateHistoryCreated`), partner-sheet emoji→brand-glyph retrofit, `firestore.rules` (`date_history`+`date_reflections`, **DEPLOYED by user**), docs (SECURITY/iOS-parity/QA-plan Pass E+N). **Cheap gates GREEN:** `:app:compileDebugKotlin` + `assembleDebug` (128MB APK) + `HomePriorityEngineTest` **25/25** (2 new DATE_REFLECTION_PENDING cases) + functions `tsc`; `wiring-scan` **🔴0 dead setters / 0 dead notif settings** for the date feature. **LIVE on QA(5554)/Sam(5556), fresh APK, software-GL:** ✅ mark-done → `date_history` synced to **both** timelines · ✅ QA reflects → AWAITING ("Saved privately 💜 — waiting for Sam"), **privacy gate holds** (QA can't see Sam pre-both) · ✅ Sam reflects → **mutual reveal side-by-side**, QA screen **live-flipped with no refresh** (observeReflected), **bidirectional E2EE decrypt** with correct You/partner labels · ✅ empty-state art (`illustration_date_memories_empty`) · ✅ timeline newest-first, per-row chips **Reflect/View** · ✅ **Home nudge** "Reflect on your date with Sam 💭" appears in BOTH the *Also waiting* (pending) and *More ways to connect* (secondary) surfaces with `glyph_date_replay`, routes to the timeline, clears after reflecting · ✅ **mark-done idempotency** (both partners tapped "We did this" on the same date → timeline shows it ONCE) · ✅ **light + dark** both date screens · ✅ cold-start no crash (nav restores back-stack) · ✅ partner-sheet retrofit renders all 5 actions as brand glyphs (no emoji-as-icons). **FOUND + FIXED — R23-DQ-001 (P2) daily-question silent re-answer data loss:** answering an already-answered daily Q logged `Write failed at …/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` — the **immutable-answer guard** (`secure allow update:false`) correctly rejecting an overwrite. Root cause: the daily-Q screen + Home sourced "answered?" from **local Room only**, so on a fresh device / cleared DB (Room empty while Firestore still holds the answer) Home showed a **stale "your turn"** and the screen offered an **editable re-answer** — and a *changed* pick would be silently dropped (the secure overwrite is denied; reveal keeps the old content). NOT caused by the date work (`git diff` = only `date_*` rule additions; daily-Q rules/datasource untouched). **Fix (uncommitted):** new **Room-first** `reconcileLocalAnswerFromFirestore` (`ui/questions/LocalAnswerMapping.kt`) — if Room lacks the answer but Firestore has it, rebuild from the read-gated couple-key payload (owner can always read their own), map option-texts, and write it back to Room; persists only when the payload actually decrypts (a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal, never a lossy re-answer) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Room-first ⇒ the normal answered path is byte-identical. **Covered by 5 new unit tests** (`ReconcileLocalAnswerTest`: Room-hit short-circuit, heal+persist+option-text-map, no-prior-answer→null, assignment-rotation guard, undecryptable→answered-but-not-persisted). Full suite **224 green**, APK builds. Live fresh-device repro is `pm clear`-gated (forbidden — App Check token) so verified by unit tests + the provably-equivalent `withLocalAnswer` render path. `QuestionDetailViewModel` (pack questions) audited — **local-only writes, no gated-secure path → not vulnerable** (no change). The **date feature is also immune** (its `hasReflected` reads Firestore, not Room). **GATED (user-only):** notification pushes (`date_reflection_partner`/`date_reflection_ready`/`date_logged`) need the 2 new functions **deployed** — code-complete + `tsc`-green, not yet live. **Verdict: R23 — Date Memories & Replay shipped + LIVE-verified flawless across the full loop + Home nudge + idempotency + light/dark; 0 defects in the feature, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked. Pending: user deploys the 2 date functions → verify the 3 date pushes live.** Uncommitted (user commits): the new date `*.kt` + models + datasources + screens, `HomePriorityEngine.kt`/`HomeViewModel.kt`/`HomeScreen.kt`, `DateMatchesScreen.kt`/`DateMatchesViewModel.kt`, `AppRoute.kt`/`AppNavigation.kt`, `FirestoreCollections.kt`, `PartnerNotificationManager.kt`/`AppMessagingService.kt`, `functions/src/dates/*` + `index.ts`, `firestore.rules`, `glyph_date_replay.xml` + `illustration_date_memories_empty.png` (+night), `HomePriorityEngineTest.kt`, `SECURITY.md`/`ClaudeiOSPlan.md`/`ClaudeQAPlan.md`/`ClaudeReport.md`. - **R22 (2026-06-29) — SECURITY.md recs #6 + #7 implemented, then full QA pass. 0 new defects, 0 FATAL.** **Work (uncommitted):** **#6 recovery-phrase save-confirmation at pairing** — the invite screen now makes the inviter **re-type one random word of the phrase** ("type word #N") before pairing feels done → "✓ Saved — you're all set." (`CreateInviteScreen.kt`). ✓ verified live on a fresh account (5558): renders, correct word → confirmed, index randomizes per entry (#5 then #8). **#7 biometric app-lock re-arms on background** — `MainActivity` lifecycle observer drops the unlocked session after the app is backgrounded past a 60s grace (`BIOMETRIC_RELOCK_GRACE_MS`), so a picked-up open phone re-prompts (not only cold-start); grace avoids re-locking on quick task-switches. Code-complete + compiles; **live re-lock pending a physical device** (emulators have no enrolled biometric/PIN). SECURITY.md/Future.md updated (#6/#7 → done). **QA run:** cheap gates ALL GREEN — build + **210 unit + 24 functions**, theme-scan CRIT **0**, painter-xml **0**; baseline both **free**, **0 active sessions**, **0 FATAL** both. Smoke: **5556 6/6** (launcher + all 5 notif cold-starts open&stay); **5554 launcher PASS + 5 FCM-delivery BLOCKs** (environmental "flaky emulator FCM, rerun" — **0 FAIL**, no crashes; shared notif code path proven by 5556). **A cornerstone live** (free → Desire Sync → Paywall, warmed "Full answer history and growth" renders). **D** carries from R20 (no rules/crypto change); **B/E** + this session's copy/bubble/#6/reveal verified R21. **INFRA finding (not app):** the 2nd/3rd emulator crashed seconds after boot with `eglMakeCurrent failed` / `Draw context is NULL` — **host GPU/EGL context exhaustion** from running 3 emulators on the hardware GPU. Fixed by killing the spare (5558) + relaunching the QA/Sam pair with **`-gpu swiftshader_indirect`** (software GL) — stable since. Saved to memory (QA-ops). **Verdict: R22 — #6 shipped+verified, #7 code-complete (needs-device), build stable + cornerstones hold, 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Uncommitted (user commits): `MainActivity.kt`, `CreateInviteScreen.kt`, `SECURITY.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md` (+ the broader session work). **N-TODAY-001 (P3, FIXED) — Today reveal state was confusing (user-reported):** after answering + revealing, the Today tab still showed the **editable answer form + a prominent "Save privately"** (looked re-answerable) AND a card titled **"Answer revealed"** that showed only the user's *own* answer (the mutual reveal is actually behind the "View reveal" button). Fixed in `LocalQuestionContent.kt` (Today-only — sole caller `DailyQuestionScreen`): the answer form now hides once `submitted`, and the card is retitled **"Your answer"** with an accurate status line ("Saved privately — waiting for your partner" / "…ready to reveal together" / "Revealed together — open View reveal to compare"). Verified live (5554, revealed state): form gone, card reads "Your answer / Cozy / Revealed together — open View reveal to compare"; 0 FATAL. - **R21 (2026-06-29) — brand-voice + UX polish round, then full ClaudeQAPlan re-run (user: "change the language… more Closer-aligned vs therapy/corporate", "ensure the daily question shows to reveal when answered", "run the full QA plan, get to screens different ways"). 0 new defects, 0 FATAL.** **Copy/UX work (uncommitted):** (1) **Brand-voice sweep** — `prompt → question` across ~26 user-facing strings (Play hub "10 questions", Wheel "Ten questions per spin", Question packs/category/composer/thread, Spin-the-Wheel, Answers, Memory Lane, Home, date ideas; counts/plurals handled; internal ids like `onPickPrompt`/`capsulePrompts`/`promptCountLabel`/`conversationPrompts` + data keys left); **clinical/corporate → Closer voice** — Home eyebrow "Tonight's prompt"→"Your daily question", status chips "Prompt ready"→"Question ready" + "Private sync"→"Just for two"; the **check-in/Outcome feature** rewarmed (survey "How satisfied are you with intimacy?"→"How close do you feel physically?", "How well do you communicate?"→"How easy is it to talk lately?", "Submit"→"Save", "Quick check-in"→"A little check-in"; **"Your Progress"→"Growing together"**, "Baseline/30-day check-in"→"Where you started/30 days in", "Change since baseline"→"Since you started", "…start tracking how your relationship feels…"→"…see how things feel between you two…"); paywall/subscription "…and insights"→"…and growth"; reveal "shared reflection"→"shared moment"; follow-up "ask one deeper follow-up?"→"go one question deeper?". (2) **Home partner bubble upgrade** — modern Coil `SubcomposeAsyncImage` (crossfade + centered-initials loading/error fallback), brand gradient ring, surface-ringed unread badge, a11y contentDescription; verified live (Sam's real photo loads in the ring). **Verification (R21 QA run):** cheap gates ALL GREEN — build + **210 unit + 24 functions**, `theme-scan` CRIT **0** (9 MAJOR/21 REVIEW), `painter-xml` **0**, `entrypoint_smoke` **6/6 on BOTH** emulators; baseline both **free**, **0 active sessions**. **Reveal-when-answered VERIFIED LIVE end-to-end** (the user's ask): answered the daily Q on both (QA "Cozy" via Today tab, Sam "Silly" via Home) → both Homes surfaced **"Reveal is ready / Reveal together"** + "Reveal ready" chip → tapped → AnswerReveal "Both answers are in" → revealed both picks ("Different picks. Honestly, useful."). **Multi-angle nav** (reached screens via different entries): daily Q via Today-tab + Home, reveal via Home card→reveal screen, Settings→"Growing together" (warmed labels render: "No check-ins yet"/"Where you started"/"30 days in"), Play→Question Packs ("250 questions"). All warmed copy renders correctly; **0 FATAL** across the whole session. **Cornerstones:** **E** re-verified live (smoke 6/6 both + partner_answered path); **N** (daily-Q + reveal) live-clean; **A/B/D** carry from R20 (no rules/crypto/games-logic change this session — diff is copy + Home-bubble UI only). **Verdict: R21 — brand-voice + bubble polish shipped + verified live across 5 surfaces; reveal-when-answered confirmed; all cheap gates green; 0 new defects, 0 FATAL. Board unchanged: 0 open P0/P1; 1 P2 (O-AGE-001 pre-ship) + 1 P3 (BRAND-DARK-COVERAGE), both user-blocked.** Also landed earlier this session (uncommitted): recovery-UX "ask your partner" copy + change-phrase desync guard, `SECURITY.md` (threat model + hardening roadmap), first instrumented test `FirstRunRenderSmokeTest` (proven to catch O-ONBOARD-001 class). **Uncommitted (user commits):** ~29 `*.kt` (copy sweep + HomeScreen/HomeViewModel + OutcomeCheckInDialog + YourProgress + recovery + crypto visibility + androidTest) + `SECURITY.md` + `docs/Engineering_Reference_Manual.md` + `ClaudeReport.md`/`ClaudeQACoverage.md`/`ClaudeQAPlan.md`/`Future.md`. diff --git a/ClaudeiOSPlan.md b/ClaudeiOSPlan.md index 5adae0cf..a99083df 100644 --- a/ClaudeiOSPlan.md +++ b/ClaudeiOSPlan.md @@ -72,6 +72,21 @@ Implement byte-compatible Swift crypto for every Android wire format: `secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` / `date_logged` notification types; until then iOS can't participate in date reflections. +- **⛔ Conversation backup + full partner-assisted restore (REQUIRED before iOS launch — R24).** Android now keeps a + couple-key-encrypted conversation backup (`couples/{id}/backup/manifest` + `.../chunks/{seq}` `enc:v1:`; snapshot + blob at Storage `users/{uid}/backups/{id}`) and a partner-assist flow (`couples/{id}/restore_requests/{uid}` with + a fresh `pub:v1:` + partner-written `keybox:v1:`; ECIES context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; + 6-digit OOB code = `truncate6(SHA-256(pubkey‖nonce))`). For **cross-platform** restore, iOS must byte-match the + `BackupCodec` JSON envelope, the couple-key wrap, and the keybox context/code so an iOS device can restore (self or + partner-assisted) from an Android partner and vice-versa — same class as the existing E2EE interop gate. Also wire + the `restore_requested` notification type. Until then iOS can't back up/restore or help a partner restore. +- **⛔ Partner-assist consent hardening (R24-b).** iOS's consent screen must mirror Android: resolve the recipient + via the user service and show their **email (plaintext anchor) + locally-decrypted display name**, and gate + Approve on **both the 6-digit code AND an explicit "I reached them" confirmation**. Mirror the two lifecycle + fixes — **delete any existing `restore_requests/{uid}` before re-creating** (a `set()` over an existing doc is a + rule-denied key-changing update) and **reject an expired request at fulfil**. Handle the new `restore_self_alert` + notification type (route to account security). The server (`onRestoreRequested`/`onRestoreFulfilled`) is shared, + so this is purely the iOS client half. ### 2.4 Screens & features to parity (~48 + new messaging) All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation diff --git a/Future.md b/Future.md index dbc68af8..aab07b9a 100644 --- a/Future.md +++ b/Future.md @@ -3,6 +3,38 @@ Non-blocking ideas: things that work today but could be better, plus feature ideas. Actual bugs (broken/incorrect behavior) live in `ClaudeReport.md`, not here. +## Backup, restore & E2EE (follow-ons to R24) + +- **Option B — relay-and-delete for messages.** Now that E2EE backup + restore exist (R24), make the Room + `conversation_cache` the **source of truth** and flip Firestore `messages` to a transient relay + (TTL / delete-after-delivery), plus **back up media into the snapshot** (since `chat_media` would then be + relay-deleted). This is the payoff the R24 backup unblocks. Rewire the chat read to local-first + live-merge + (additive, flag-guarded — dedupe Room∪Firestore by message id). +- **Backup trigger hardening.** Backups currently run opportunistically from `HomeViewModel.loadHome` (throttled). + Move to **WorkManager** (deferred, retried, network/battery-constrained) + an app-background trigger for + reliability; add a **resumable, paginated initial backfill** for very large existing histories. +- **Settings visibility + control.** "Last backed up: {time} · {N} messages" indicator + manual "Back up now" / + "Restore history", and an **opt-out** toggle for users who want zero server retention. Include a dedicated + **"Recent restore activity"** list — the R24-b `restore_self_alert` entries already land in `notification_queue` + as the raw audit; this surfaces them so the owner can review restores on their account in one place. +- **Email-verification challenge for partner-assisted restore (strongest anti-account-takeover control).** Before + a restore request is honored, send a code to the account's **registered email** and require the recipient to + enter it. A phished-password attacker who lacks inbox access can't complete it (the couple-email match that + defeats the on-screen identity check does NOT defeat this). Needs mail infra (SendGrid / a Firebase Auth action) + — deferred for that reason. R24-b shipped the on-screen identity + confirm + owner self-alert as the pragmatic + interim. +- **Restore-request lifecycle cleanup.** A `restore_requests` doc left at `READY` (partner wrapped the key but the + recipient never completed) leaves an ECIES `keybox` — ciphertext sealed only to the recipient, useless to + anyone else, but untidy. Add a **scheduled cleanup** of expired requests (and their keyboxes). R24-b already + enforces expiry at fulfil time and deletes any stale request before a re-request. +- **Owner-alert precision.** The R24-b "was this you?" self-alert also reaches the *requesting* device (harmless). + Optionally exclude it via a client-written token hint on the request (would add a rules-allowed field). +- **Couple-key rotation / forward secrecy.** A couple-key compromise exposes all history incl. backups (no FS + today). Add rotation (both devices re-key) — hard but the right long-term hardening. +- **Server-independent anti-rollback freshness.** A malicious server could serve a stale manifest to hide recent + messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic + freshness signal for the Option-B world. + ## UI _(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 2f03102e..954e1857 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1163,6 +1163,60 @@ SCRIPTS.md These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now. +### R24-BACKUP — E2EE conversation backup + full partner-assisted restore (design + re-introduction hazards) +**What it is**: devices keep a couple-key-encrypted backup of all conversations so a new/wiped device can restore +history, and a partner can fully restore for the other (key + content, no phrase). Files: +`data/backup/BackupManager.kt` (incremental append + compaction), `data/backup/BackupRestoreManager.kt` (restore +into the `conversation_cache` Room DB), `data/backup/RestoreManager.kt` (partner-assist for both roles), +`data/remote/FirestoreBackupDataSource.kt` (manifest/chunks + restore_requests), `crypto/CoupleKeyTransfer.kt` +(couple-key ECIES wrap + the OOB verification code), `functions/src/backup/onRestoreRequested.ts`. +**Wire formats / layout**: `couples/{id}/backup/manifest` (pointers + `generation`), `.../backup/manifest/chunks/{seq}` +(each `payload` is `enc:v1:` of a `BackupCodec` JSON batch), snapshot blob at Storage `users/{uid}/backups/{id}` +(couple-key ciphertext, tokenized URL in the manifest), `couples/{id}/restore_requests/{recipientUid}` (fresh +`pub:v1:` + partner-written `keybox:v1:`). Keybox ECIES context = `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; +OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`. +**Re-introduction hazards**: +- **Both partners write the same couple backup** → converge only via **message-id dedupe** (restore upsert) + + **manifest `generation` CAS** in a transaction. Never blind-overwrite the manifest. **Delete folded chunks + + the previous snapshot ONLY after the manifest commits** (crash-safe); a lost CAS must orphan-clean its just- + uploaded blob and retry. +- **Append misses mutations.** `appendChunk` only catches messages with a NEW `createdAt`; deletes/reactions on old + messages are updates → captured only by **compaction's full-state re-read**. Don't "optimize" compaction into an + append-only fold. +- **Only back up resolved server timestamps** (`getTimestamp` non-null) — a pending write has a null timestamp and + would corrupt the cursor. +- **Backup blobs live under `users/{uid}/backups/`** (uploader-scoped write, so Storage rules can authorize it + + the existing `onUserDelete` `users/{uid}/` cleanup covers them). Do NOT move them under `couples/` without adding + membership-checked write auth (Storage rules can't read Firestore) + a `couples/` delete cleanup. +- **Partner-assist security is the OOB code.** B must **type** the 6-digit code A reads aloud before wrapping the + 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. +- **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 + `restore_requests/{uid}` doc that's an **update** changing `recipientPublicKey`/`requestNonce`, which matches **no** + rule (the keybox rule needs `auth.uid != recipientUid`; the status-only rule can't touch keys) → silent + `PERMISSION_DENIED`. So a retry after an expired/abandoned request fails. `RestoreManager.requestRestore` now + **deletes any existing request first**, then creates fresh. Don't drop the delete. +- **Enforce request expiry at fulfil (R24-b, Bug B).** `expiresAt` was advisory — nothing checked it, so a partner + could approve a stale request and wrap the key to a since-replaced pubkey. `fulfillRestore` now **rejects when + `now > expiresAt`** (`expiresAt <= 0` = no-expiry legacy is allowed); the consent screen treats expired/absent as + "no active request" (its own empty state, distinct from a live one). +- **Partner-assist consent shows identity + requires a confirm (R24-b).** The consent screen resolves the recipient + via `userRepository.getUser(partnerUid)` — **email is plaintext (the anti-impersonation anchor), displayName is + decrypted locally** (the approving partner holds the couple key; the server can't decrypt it, so this is + necessarily client-side). Approve is gated on `code.length==6 && confirmed`. The email/name/confirm are **UX + + accidental-approval + social-engineering-friction** controls, NOT a takeover defense (the takeover email matches); + the OOB code + the owner self-alert are the takeover controls. Keep the identity strictly client-side — never send + displayName to a function (breaks E2EE). +- **Owner self-alerts (R24-b).** `onRestoreRequested` sends a SECOND notification — to the **recipient's own + devices** (`type:'restore_self_alert'`, quiet-hours **bypassed**, deduped via `couples/{id}.lastRestoreSelfAlertAt` + within 60s) — plus `onRestoreFulfilled` (onUpdate, guarded to the single REQUESTED→READY edge) on key transfer. + Each notification branch is independently try/caught so one failing token-fetch never aborts the other. Server + bodies stay generic (can't name the recipient — E2EE). Client type wired in `PartnerNotificationManager` + (`RESTORE_SELF_ALERT` → `isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`). + ### R23-DQ-001 — sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload` **Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** — the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved". **Root cause**: `DailyQuestionViewModel.loadDailyQuestion` and `HomeViewModel` derived answered-state from **local Room/prefs only** (`localAnswerRepository.getAnswer` / `observeAnswers` → `answeredQuestionIds`), with no fallback to Firestore. Room and Firestore can legitimately diverge (Auth + Firestore persist across a reinstall; the local answer store does not), so the app offered an action the rules forbid.