diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index c174aaf4..2f03102e 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1163,6 +1163,12 @@ 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. +### 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. +**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) — **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). `QuestionDetailViewModel` (pack questions) is **local-only** and not affected. +**Re-introduction risk**: any UI that decides whether to offer a **submit-once / immutable** write (daily answers, date reflections, anything with a read-gated `secure/payload` whose `allow update:false`) must treat **Firestore as authoritative for existence**, not local cache — a local-only check re-offers the action after the local DB is lost and the immutable rule rejects the re-write silently. The date feature already does this (its `hasReflected` reads Firestore). When adding a new private→reveal collection, reconcile from Firestore on load and verify the fresh-device path (Pass N **R23-DQ-001** check), since `pm clear` (the faithful repro) is blocked in our emulator setup (App Check token). + ### B-ABANDON-001 — never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys **Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** — stranded, blocking new games — with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{…}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused. **Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** — so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.)