diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index bb4cae33..046f8d03 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -15,10 +15,10 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin | Pass | Manual section to read first | |---|---| | A — Couple-shared premium | [Premium-gated features and gate pattern](docs/Engineering_Reference_Manual.md#premium-gated-features-and-gate-pattern) · [Billing](docs/Engineering_Reference_Manual.md#billing) | -| B — Games lifecycle | [Game session push semantics (idempotent flag-claim)](docs/Engineering_Reference_Manual.md#game-session-push-semantics-idempotent-flag-claim) · [Foreground game-alert banner](docs/Engineering_Reference_Manual.md#foreground-game-alert-banner-r10) · [F-RACE-001](docs/Engineering_Reference_Manual.md#f-race-001--duplicate-game-start-push-on-rapid-partner-update) | -| C — Visual (light+dark) | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) · [C-NAV-001](docs/Engineering_Reference_Manual.md#c-nav-001--back-from-home-resurfaces-onboardingauth) · [Back-stack gotchas](docs/Engineering_Reference_Manual.md#back-stack-gotchas-c-nav-002-c-nav-003) · [C-HOME-001](docs/Engineering_Reference_Manual.md#home-duplicate-pending-action-card-c-home-001) | +| B — Games lifecycle | [Game session push semantics (idempotent flag-claim)](docs/Engineering_Reference_Manual.md#game-session-push-semantics-idempotent-flag-claim) · [Foreground game-alert banner](docs/Engineering_Reference_Manual.md#foreground-game-alert-banner-r10) · [F-RACE-001](docs/Engineering_Reference_Manual.md#f-race-001-duplicate-game-start-push-on-rapid-partner-update) | +| C — Visual (light+dark) | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) · [C-NAV-001](docs/Engineering_Reference_Manual.md#c-nav-001-back-from-home-resurfaces-onboarding-auth) · [Back-stack gotchas](docs/Engineering_Reference_Manual.md#back-stack-gotchas-c-nav-002-c-nav-003) · [C-HOME-001](docs/Engineering_Reference_Manual.md#home-duplicate-pending-action-card-c-home-001) | | D — Security & encryption | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Firestore security rules](docs/Engineering_Reference_Manual.md#firestore-security-rules) · [Encryption versions](docs/Engineering_Reference_Manual.md#encryption-versions) | -| E — Notifications | [Notifications](docs/Engineering_Reference_Manual.md#notifications) · [Notification deep-link routing](docs/Engineering_Reference_Manual.md#notification-deep-link-routing) · [E-GAME-001](docs/Engineering_Reference_Manual.md#e-game-001--notification-deep-link-landed-in-stalefinished-game) · [E-GAME-002](docs/Engineering_Reference_Manual.md#e-game-002--game-start-push-easy-to-miss-when-app-is-foreground) | +| E — Notifications | [Notifications](docs/Engineering_Reference_Manual.md#notifications) · [Notification deep-link routing](docs/Engineering_Reference_Manual.md#notification-deep-link-routing) · [E-GAME-001](docs/Engineering_Reference_Manual.md#e-game-001-notification-deep-link-landed-in-stale-finished-game) · [E-GAME-002](docs/Engineering_Reference_Manual.md#e-game-002-game-start-push-easy-to-miss-when-app-is-foreground) | | F — Resilience | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Known limitation: single-device keys](docs/Engineering_Reference_Manual.md#known-limitation-single-device-keys) | | G — Account creation / fake-account | [Authentication and pairing flow](docs/Engineering_Reference_Manual.md#authentication-and-pairing-flow) · [Rate limiting on accept](docs/Engineering_Reference_Manual.md#rate-limiting-on-accept) | | H — Branding & artwork | `ClaudeBrandingReview.md` (this repo) · `docs/brand/visual-identity.md` | @@ -698,7 +698,7 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera - Branding *defects* (mis-colored, clipped, off-brand, low-contrast art) are bugs → `ClaudeReport.md`. Pure "works but could be warmer / a feature idea" → `Future.md` `## QA`. New art to create → `ClaudeBrandingReview.md`. -### Pass I — Performance & route efficiency (jank, redundant reads, caching) [FUTURE.md P14] +### Pass I — Performance & route efficiency (jank, redundant reads, caching) [Future.md P14] Before store polish, profile **every top route** and **every high-cardinality list** for jank, repeated Firestore reads, missing cache use, and slow navigation. Drive each route as a user and instrument reads/frames. - **Frame / jank:** scroll every long list (Messages inbox + conversation, Answer History, Question Packs, Past Games, @@ -715,11 +715,11 @@ reads, missing cache use, and slow navigation. Drive each route as a user and in - **Deliverable:** a reusable **route smoke-test checklist** (every top route × {load time · jank · read count}), captured as a runnable script so each round re-checks cheaply. - **Remediation when found:** lazy-load/page large lists; cache local question/category data; dedupe + scope snapshot - listeners; skip redundant fetches on tab switches; add skeleton/loading states (cf. FUTURE.md P8) over blocking spinners. + listeners; skip redundant fetches on tab switches; add skeleton/loading states (cf. Future.md P8) over blocking spinners. - Findings: real jank/leak/redundant-read = bug → `ClaudeReport.md` (P2; **P1** if it ANRs or leaks listeners, **P0** if it drops data); "could be smoother / add skeletons" → `Future.md` `## QA`. -### Pass J — Accessibility (font scale · contrast · screen reader · targets · keyboard · reduce-motion) [FUTURE.md P15] +### Pass J — Accessibility (font scale · contrast · screen reader · targets · keyboard · reduce-motion) [Future.md P15] Every **primary flow** must be usable with accessibility settings on. Enable each setting and walk the core flows (auth, onboarding, pairing, Home, a full game, daily question + reveal, Messages, Paywall, Settings) end to end. This is the deep home for a11y; the Pass C contrast/font spot-checks feed into it. diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index cc560730..203d7c34 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -11,6 +11,9 @@ This is the source of truth for Closer's architecture, security model, data mode - Before touching crypto, read [End-to-end encryption model](#end-to-end-encryption-model) and the real files referenced there. - Before adding a Cloud Function, read [Cloud Functions](#cloud-functions) and match the existing module pattern. - Before changing the daily-question flow, read [Daily question lifecycle](#daily-question-lifecycle) and `app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`. +- Before changing notifications, read [Notifications](#notifications) — specifically [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim), [Foreground game-alert banner](#foreground-game-alert-banner-r10), and [Notification deep-link routing](#notification-deep-link-routing). +- Before gating a feature on premium, read [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern). +- Before debugging or changing any area, scan [Known landmines and recent fixes](#known-landmines-and-recent-fixes) — it lists bugs that already cost real debugging time and are easy to re-introduce. - [Where to look first](#where-to-look-first) points new engineers at the most important files. --- @@ -27,10 +30,15 @@ This is the source of truth for Closer's architecture, security model, data mode 8. [Cloud Functions](#cloud-functions) 9. [Billing](#billing) 10. [Notifications](#notifications) + - [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim) + - [Foreground game-alert banner](#foreground-game-alert-banner-r10) + - [Notification deep-link routing](#notification-deep-link-routing) + - [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern) (under Billing) 11. [iOS-specific notes](#ios-specific-notes) 12. [Build and release](#build-and-release) 13. [Engineering conventions](#engineering-conventions) -14. [Where to look first](#where-to-look-first) +14. [Known landmines and recent fixes](#known-landmines-and-recent-fixes) +15. [Where to look first](#where-to-look-first) --- @@ -311,9 +319,11 @@ iOS does not generate or store a recovery phrase in the current build. iOS coupl | Version | Name | Meaning | | --- | --- | --- | -| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. Used by iOS couples until E2EE parity ships. | -| 1 | `MIGRATING` | A couple key exists but historical content is still being rewritten by both partners. Kept for backwards compatibility with older couples; no new couples should be created at v1. | -| 2 | `STRICT` | All answer-bearing paths require encryption. Default for all new Android couples. | +| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. **iOS only** — every iOS-originated couple is created at v0 because the iOS port does not implement E2EE today (see [iOS E2EE gap](#ios-e2ee-gap)). No Android couple is ever created at v0. | +| 1 | `MIGRATING` | Reserved for a hypothetical in-flight migration. **No couple exists at v1 in production today.** The constant is documented in `EncryptionVersion.kt` for forward-compatibility; do not write v1 from new code without a concrete migration plan. | +| 2 | `STRICT` | All answer-bearing paths require encryption. **Default for every Android-originated couple**; the only state `acceptInviteCallable.ts` writes when E2EE fields are present. | + +**What the code actually does**: the Android `EncryptionVersion` object defines only `STRICT = 2` and `NEW_COUPLE_DEFAULT = STRICT`. v0 and v1 are conceptual states that exist on paper for iOS compatibility and migration planning; nothing in the Android source path writes them. Do not change this without auditing iOS cross-platform pairing (a mixed v0/v2 couple is the only combination that the rules tolerate today, and only because Android handles plaintext gracefully when the partner is iOS). The Cloud Function `acceptInviteCallable.ts` derives `encryptionVersion` from whether E2EE fields are present: if `wrappedCoupleKey`, `kdfSalt`, and `kdfParams` are all non-null, the couple is created at v2; otherwise v0. This keeps the iOS-and-Android-different-defaults case from breaking. @@ -342,9 +352,9 @@ The Argon2id parameters are deliberately chosen to take ~2-3 seconds on a mid-ra See [Recovery phrase flow](#recovery-phrase-flow). The recovery phrase is the only human-readable secret. It is never sent to the server in plaintext. -### Sealed-answer partner-proof mode +### Sealed-answer partner-proof mode (schemaVersion 3 only) -Sealed answers (`schemaVersion = 3`) provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys. +Sealed answers provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys. **This is the schemaVersion 3 path** — thread messages and the legacy answer path. The current daily-answer default is schemaVersion 2 (couple-key, see [Reveal flow](#reveal-flow)), which uses a different gating model. Flow: @@ -443,7 +453,7 @@ The function uses `CST_OFFSET_HOURS = -6` and does not account for daylight savi - In **summer**, the date key is computed by adding -6h to UTC. The 6 PM cron is at 23:00 UTC, so `date` is correct in summer. - In **winter**, the same 23:00 UTC cron fires at 5 PM local. Adding -6h gives the local date as intended. -In practice the date key is correct most of the time, but the comment "America/Chicago 6:00 PM == 23:00 UTC" is **only true in CDT**. During CST, the cron actually runs at 5 PM local and the offset is still correct. The fix is to use a proper IANA tz library (e.g. `date-fns-tz`) rather than a hardcoded offset. Track this in `FUTURE.md`. +In practice the date key is correct most of the time, but the comment "America/Chicago 6:00 PM == 23:00 UTC" is **only true in CDT**. During CST, the cron actually runs at 5 PM local and the offset is still correct. The fix is to use a proper IANA tz library (e.g. `date-fns-tz`) rather than a hardcoded offset. Track this in `Future.md`. ### Answer write @@ -465,16 +475,27 @@ Token lookup reads both a legacy `fcmToken` field on the user doc and a dedicate ### Reveal flow -The reveal happens client-side after both partners have submitted: +The reveal path differs by schema version: -1. Each app checks for the partner's `answers/{partnerId}` doc. -2. Each app writes a `releaseKeys/{partnerId}.encryptedAnswerKey` containing the sender's one-time key wrapped to the partner's ECIES public key. -3. Each app reads the `releaseKeys/{selfUserId}.encryptedAnswerKey` written by the partner. -4. Each app unwraps the key with its private key and decrypts the partner's `encryptedPayload`. -5. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`. -6. The reveal screen shows both answers side-by-side. +**SchemaVersion 2 (couple-key daily answers) — the current default.** The answer doc at `couples/{coupleId}/daily_question/{date}/answers/{userId}` is **metadata only** — no answer content. The encrypted payload lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` with a single `encryptedPayload` field (`enc:v1:`). The Firestore rules for that subdoc grant read access to the owner unconditionally, AND to the partner **only if** the partner has also submitted their own answer to the same date (checked by `exists(.../answers/{request.auth.uid})` in `firestore.rules`). This is the cryptographic "private until both answer" gate — there is no per-answer key handshake, just a server-side read predicate. -The reveal state is gated by Firestore rules: only the sender writes the keybox, only the recipient reads it. The sealed payload is created with `answerKeyReleased: false`; the rules only allow the reveal-metadata fields (`isRevealed`, `answerKeyReleased`, `updatedAt`) to change after creation. +1. Each app writes its own `answers/{userId}` metadata doc + its own `answers/{userId}/secure/payload` subdoc with `enc:v1:` content. +2. Once the partner's metadata doc exists, the rules unlock read access to your `secure/payload` for the partner (and vice versa). Both apps decrypt with the shared couple key. +3. The metadata `isRevealed` flag flips after both have read; that's what triggers the `partner_opened_answer` push (see `functions/src/questions/onAnswerRevealed.ts`). + +**SchemaVersion 3 (sealed / partner-proof — used for thread messages).** The original sealed-answer flow: + +1. User composes message. +2. App generates a one-time AES-256-GCM key. +3. App computes SHA-256 commitment over canonical JSON payload. +4. App seals payload with one-time key → writes `encryptedPayload` + `commitmentHash` to Firestore. +5. App stores one-time key locally in `PendingAnswerKeyStore`. +6. Partner does the same. +7. After both messages exist, each app reads partner's public key from `users/{partnerId}/devices/primary`, wraps its own one-time key with ECIES P-256, writes keybox to `releaseKeys/{partnerId}`. +8. Each app reads the keybox written for them, unwraps with private key, decrypts partner's sealed payload. +9. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`. + +**Adding a new answer-bearing path**: decide upfront whether schemaVersion 2 (couple-key, simple, current default) or schemaVersion 3 (sealed, partner-proof, more code) is appropriate. New schemas require new `isXxxAnswerCreate` helpers in `firestore.rules` AND new write paths in the data source. ### Thread questions @@ -590,15 +611,24 @@ isSignedIn() request.auth != null isOwner(uid) request.auth.uid == uid isCouplesMember(coupleId) request.auth.uid in couples/{coupleId}.userIds isValidInviteCode(code) matches('^[a-zA-Z0-9]{6}$') +isNotAlreadyPaired() caller has no existing coupleId isImmutable(fields) diff(...).affectedKeys().hasOnly(fields) +isValidSwipeAction(action) 'love' | 'maybe' | 'skip' +isValidDatePlanStatus(status) 'draft' | 'planned' | 'completed' +isValidBucketListCategory(category) whitelist of category strings isCiphertext(value) matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$') +cipherOrAbsent(data, key) data[key] is ciphertext or absent +isDatePlanContentEncrypted(data) date-plan content fields are enc:v1: or absent +coupleEncryptionEnabled(coupleId) couple.encryptionVersion >= 1 isSealedPayload(value) matches('^sealed:v1:[A-Za-z0-9_-]{80,}$') isKeybox(value) matches('^keybox:v1:[A-Za-z0-9_-]{120,}$') isCommitmentHash(value) matches('^sha256:[A-Za-z0-9_-]{43}$') -isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256: -isSealedAnswerUpdate() only reveal-metadata fields -isStartingEncryptionMigration() v0/v1 → v1 with empty migration map -isCompletingOwnEncryptionMigration() v1 → v1/v2 with self in migration map +isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256: (schemaVersion 3, legacy) +isSealedAnswerUpdate() only reveal-metadata fields (schemaVersion 3) +isCoupleKeyAnswerCreate(data) metadata-only shape, schemaVersion 2 +isCoupleKeyAnswerUpdate() only isRevealed/updatedAt flip (schemaVersion 2) +isSealedThreadAnswerCreate(data) sealed shape, no answerDate/isRevealed (threads, schemaVersion 3) +isSealedThreadAnswerUpdate() only answerKeyReleased/updatedAt flip (threads) isUpdatingRecoveryWrap() only wrappedCoupleKey/kdfSalt/kdfParams isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt ``` @@ -615,7 +645,7 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt **`couples/{coupleId}/daily_question/...`** — server-only writes. Daily-question assignment and answer-related subcollections are tightly constrained. -**`couples/{coupleId}/daily_question/{date}/answers/{userId}`** — the answer is private to its author until reveal. The `isSealedAnswerCreate` / `isSealedAnswerUpdate` helpers enforce the sealed-answer shape. Legacy answers (`schemaVersion` ≠ 3) must use `enc:v1:` ciphertext. +**`couples/{coupleId}/daily_question/{date}/answers/{userId}`** — the answer is private to its author until reveal. **The metadata doc is content-free** (schemaVersion 2): it holds only `userId`, `questionId`, `answerType`, `schemaVersion`, `answerDate`, `createdAt`, `updatedAt`, `isRevealed` so the partner can see THAT you answered ("your turn" indicator) without leaking the content. The actual `encryptedPayload` lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` and is only readable by the partner once the partner has also submitted (`exists(.../answers/{request.auth.uid})`). The owner always reads their own subdoc. The subdoc shape is `keys().hasOnly(['encryptedPayload'])` and `isCiphertext(encryptedPayload)`. See [Reveal flow](#reveal-flow) for the full path. Legacy schemaVersion 3 answers (sealed:v1:) follow the `isSealedAnswerCreate`/`isSealedAnswerUpdate` helpers and use the `releaseKeys/` subdoc for the ECIES-wrapped keybox. **`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** — create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced. @@ -981,10 +1011,11 @@ firebase deploy --only functions ### Files that must never be committed -Add to every clone's `.gitignore`: +The authoritative list lives in `.gitignore` at the repo root. The current entries the agent workflow relies on are: ```text -FUTURE.md +# Private project docs (agent-only, never commit) — see .gitignore +FUTURE.md # uppercase; legacy name in gitignore HISTORY.md PROJECT.md STRUCTURE.md @@ -996,7 +1027,7 @@ SCRIPTS.md .kotlin/ ``` -These are agent-only or workspace-only docs and have no place in the public repo. +> **Note (2026-06): the gitignore list uses `FUTURE.md` (uppercase) but the tracked file is `Future.md` (mixed case).** Linux filesystems are case-sensitive so these are different paths; the gitignore does not actually block `Future.md`. The ClaudeQA docs (`ClaudeQAPlan.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `ClaudeBrandingReview.md`, `ClaudeiOSPlan.md`) and `Future.md` are explicitly tracked. If you create new top-level docs and want them gitignored, either match the existing case in `.gitignore` (`Future.md`) or pick a case and use it consistently. Do not block `Future.md` and then create `future.md` thinking you're safe. ### Versioning