From 578851964c63bbc6c7ea59e9f22335bad9495e64 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 21 Jun 2026 18:44:31 -0500 Subject: [PATCH] docs: write Engineering Reference Manual with verified facts, fix Bishop pass issues --- docs/Engineering_Reference_Manual.md | 954 +++++++++++++++++++++++++++ 1 file changed, 954 insertions(+) create mode 100644 docs/Engineering_Reference_Manual.md diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md new file mode 100644 index 00000000..902e2efb --- /dev/null +++ b/docs/Engineering_Reference_Manual.md @@ -0,0 +1,954 @@ +# Closer Engineering Reference Manual + +> Private daily questions for couples who want honest answers before shared conversations. + +This is the source of truth for Closer's architecture, security model, data model, and engineering conventions. Read it before changing anything that crosses layers: auth, crypto, Firestore rules, Cloud Functions, billing, or cross-platform data contracts. + +## How to use this document + +- Start with [System overview](#system-overview) and [Repository layout](#repository-layout). +- Before touching Firestore paths, read [Firestore data model](#firestore-data-model) and [Firestore security rules](#firestore-security-rules). +- 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`. +- [Where to look first](#where-to-look-first) points new engineers at the most important files. + +--- + +## Table of Contents + +1. [System overview](#system-overview) +2. [Repository layout](#repository-layout) +3. [Authentication and pairing flow](#authentication-and-pairing-flow) +4. [End-to-end encryption model](#end-to-end-encryption-model) +5. [Daily question lifecycle](#daily-question-lifecycle) +6. [Firestore data model](#firestore-data-model) +7. [Firestore security rules](#firestore-security-rules) +8. [Cloud Functions](#cloud-functions) +9. [Billing](#billing) +10. [Notifications](#notifications) +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) + +--- + +## System overview + +Closer is a couples relationship app. The product goal is **private, mutual-reveal relationship questions with real encryption and calmer UX**. It is not a social network: there are no public feeds, no likes, and no followers. The core loop is one partner answers a private prompt, the other partner answers independently, and both choose when to reveal. + +### Three platform split + +| Platform | Stack | Role | +| --- | --- | --- | +| Android | Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore | Reference implementation; owns the E2EE crypto layer | +| iOS | SwiftUI, MVVM, async/await, Firebase iOS SDK, RevenueCat | Screen parity with Android; E2EE cross-compatibility not yet implemented | +| Backend | Firebase Auth, Firestore, Cloud Functions (TypeScript), FCM, App Check | Shared source of truth for both apps | + +### Data ownership + +- Each user owns their own `users/{uid}` document and subcollections. +- A couple owns the `couples/{coupleId}` document and all subcollections beneath it. +- The server (Cloud Functions / Admin SDK) owns invite creation, daily question assignment, entitlement events, and any cross-user writes. +- Clients never write to another user's document or to another couple's document. +- E2EE answer content is encrypted on the device. The server sees only ciphertext. + +### Key architectural decisions + +- **Clean architecture on Android** — `core/`, `data/`, `domain/`, `ui/` layers with Hilt wiring. The `crypto/` package is a peer of `core/` because it has its own internal state and lifecycle. +- **MVVM on iOS** — `AppState` ObservableObject + `EnvironmentObject`, per-feature ViewModels. The codebase is small enough that no DI framework is used; dependencies are passed by hand via initializers and `shared` singletons. +- **Server-mediated pairing** — 6-character invite codes are enumerable, so invite reads/writes are server-side only. Direct client writes to `invites/` are denied in Firestore rules. +- **Server-verified billing** — RevenueCat webhooks write entitlements; the Android app observes Firestore for premium state, with the local RevenueCat SDK as a fallback. iOS does not yet observe Firestore entitlements and reads RevenueCat only. +- **Local-first questions** — Question content ships in the app so daily questions load instantly; only assignment and sync hit the network. +- **Encrypted answers, plaintext couple metadata** — Couple names, photo URLs, and rhythm stats (`streakCount`, `lastAnsweredAt`) are plaintext. Only answer content and key material is encrypted. + +--- + +## Repository layout + +### Android + +```text +app/src/main/java/app/closer/ +├── MainActivity.kt +├── core/ +│ ├── analytics/ # Firebase Analytics + Crashlytics wrappers +│ ├── billing/ # EntitlementChecker + FirestoreEntitlementChecker +│ ├── crash/ # CrashReporter abstraction +│ ├── feature/ # Feature flags +│ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks +│ ├── notifications/ # FCM service, TokenRegistrar, quiet hours +│ └── security/ # Auth rate limiter +├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores +├── data/ +│ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences +│ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers +│ └── repository/ # Repository implementations +├── domain/ +│ ├── model/ # Plain data classes +│ └── repository/ # Repository interfaces +├── di/ # Hilt modules +└── ui/ # Compose screens + ViewModels + ├── answers/ # Answer write/reveal/history + ├── brand/ # Logo, splash, illustrated empty states + ├── challenges/ # Connection Challenges + ├── dates/ # Date builder, matches, bucket list + ├── desiresync/ # Preferences alignment exercise + ├── games/ # Game scaffolding + ├── home/ # Home dashboard + partner state + ├── howwell/ # How Well Do You Know Me game + ├── memorylane/ # Time capsules + ├── onboarding/ # Onboarding screens + ├── outcomes/ # 30/60/90 day check-ins + ├── pairing/ # Invite create/accept/confirm/recovery + ├── paywall/ # Subscription paywall + ├── play/ # Play hub + ├── questions/ # Daily question, packs, history + ├── settings/ # All settings screens (account, privacy, subscription, security…) + ├── thisorthat/ # This or That game + ├── theme/ # CloserTheme + ├── wheel/ # Spin the wheel + ├── components/ # Shared Compose components + └── auth/ # Auth screens +``` + +The `app/src/main/res/drawable-nodpi/` folder holds brand illustrations (onboarding, invite, paywall, subscription, history). + +### iOS + +```text +iphone/ +├── ARCHITECTURE_AUDIT.md # iOS port blueprint (generated from Android source) +├── project.yml # XcodeGen spec +├── Package.swift # SPM dependency manifest +├── Closer.entitlements # Push, Keychain, App Groups +├── Info.plist # Bundle config, push entitlement, URL schemes +├── GoogleService-Info.plist # Firebase config (template; not committed) +└── Closer/ + ├── CloserApp.swift # @main, AppState, AppDelegate, RevenueCat init + ├── ContentView.swift # Root NavigationStack + TabView + ├── Core/ + │ ├── Auth/AuthService.swift + │ ├── Billing/BillingService.swift + │ └── Notifications/NotificationService.swift + ├── Crypto/ # Intended for CryptoKit E2EE parity — currently empty + ├── Models/ # Codable Firestore + domain types + ├── Services/FirestoreService.swift # Firestore + callable wrappers + ├── Theme/CloserTheme.swift # Colors, typography, spacing + ├── Components/ # Shared SwiftUI components + ├── Navigation/ # Root routing (paired with ContentView) + ├── Onboarding/ # Onboarding, login, signup + ├── Pairing/ # Invite code, partner confirm, recovery + ├── Home/ # Home dashboard, partner mirror + ├── Questions/ # Daily question, answer reveal, history, packs + ├── Play/ # Play hub + games + ├── Wheel/ # Spin wheel + ├── Dates/ # Date swipe, matches, builder, bucket list + ├── Settings/ # Settings, paywall, subscription, help, data export + ├── Paywall/ # (placeholder; paywall lives in Settings for now) + └── Resources/ # Illustrations, assets +``` + +The iOS `Crypto/` folder is **intentionally empty** today. The Swift port defers E2EE parity to a follow-up batch. The current iOS path creates `encryptionVersion = 0` couples and uses the plaintext answer path. See [iOS E2EE gap](#ios-e2ee-gap) for the precise scope and risk. + +### Cloud Functions + +```text +functions/src/ +├── index.ts # Admin SDK init + exports +├── billing/ +│ ├── revenueCatWebhook.ts # HTTPS webhook — Ed25519 signature verify +│ ├── entitlementLogic.ts # Idempotent entitlement event handlers +│ ├── entitlementLogic.test.ts # Vitest unit tests +│ └── syncEntitlement.ts # Callable — forced re-sync from client +├── couples/ +│ ├── createInviteCallable.ts # Server-side invite creation +│ ├── acceptInviteCallable.ts # Code validation, couple creation, rate limit +│ ├── leaveCoupleCallable.ts # Voluntary leave + cleanup +│ ├── onCoupleLeave.ts # Trigger when coupleId cleared +│ ├── submitOutcomeCallable.ts # 30/60/90 day check-in +│ └── scheduledOutcomesReminder.ts # Pub/Sub schedule: 30/60/90 reminders +├── dates/ +│ └── createDateMatch.ts # Trigger: mutual-love → date match +├── games/ +│ └── onGameSessionUpdate.ts # Trigger: game state changes → notify partner +├── notifications/ +│ ├── reminders.ts # sendDailyQuestionReminder, sendPartnerAnsweredNotification +│ ├── sendGentleReminderCallable.ts # Manual gentle reminder +│ └── gameRetention.ts # Challenge day reminders, capsule unlocks +├── questions/ +│ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable +│ ├── onAnswerWritten.ts # Trigger: notify partner on answer +│ └── onMessageWritten.ts # Trigger: thread messages +├── security/ +│ └── checkDeviceIntegrity.ts # Play Integrity verdict verification +└── users/ + └── onUserDelete.ts # Auth user deletion cascade +``` + +There is no `auth/` module. Authentication is handled entirely by the Firebase Auth client SDK; the Admin SDK is used in the `users/onUserDelete.ts` trigger and in `couples/acceptInviteCallable.ts` to read user docs. + +### Shared configuration + +```text +firestore.rules # Security rules (single source of truth) +firestore.indexes.json # Composite indexes and TTL field overrides +seed/ # Question pack JSON and local DB generation +server/ # Optional Express webhook/health service (not client-facing) +docs/ # This manual, QA notes, release prep, store assets +``` + +--- + +## Authentication and pairing flow + +### Auth providers + +Firebase Auth supports three sign-in paths: + +1. **Anonymous** — used for the trial onboarding flow. The user can use the app without an account and is prompted to upgrade before any irreversible action. +2. **Email/password** — standard sign-up and login. +3. **Google Sign-In** — via Credential Manager on Android, the Google Sign-In SDK on iOS. + +The Android `FirebaseAuthDataSource` exposes the standard Firebase upgrade paths; iOS uses the same Firebase Auth APIs through `AuthService.swift`. Anonymous accounts are linked to email/Google credentials when the user upgrades. If linking fails because the credential already exists, the app signs into the existing account. + +### Pairing flow + +The pairing flow is server-mediated because 6-character codes are enumerable. The flow is identical on both platforms. + +```text +Inviter (Android or iOS) + 1. Generate couple keyset + recovery phrase (CoupleEncryptionManager on Android; + iOS skips step 1 — see iOS E2EE gap). + 2. Generate 6-char code. + 3. Encrypt phrase with code (RecoveryKeyManager.encryptPhraseWithCode) — Android only. + 4. Call createInviteCallable(code, wrappedKey, salt, params, encryptedPhrase). + 5. Server writes /invites/{code} with 24h TTL and a `notification_queue` entry. + 6. Inviter shows code, copies/shares it. + +Acceptor (any platform) + 7. Enter code. + 8. Call acceptInviteCallable({ code }). + 9. Server validates code, creates /couples/{coupleId}, links both user docs, + returns wrappedKey + encryptedRecoveryPhrase. + 10. Acceptor decrypts phrase with code (decryptPhraseWithCode) — Android only. + 11. Acceptor unwraps keyset with phrase (CoupleEncryptionManager.unwrapAndStore) — Android only. + 12. Both users now share the same couple key (or plaintext for iOS couples). +``` + +The `couples` document is **never** written by clients. Even legitimate field updates like `streakCount` go through Cloud Functions or are blocked by rules. See [Firestore security rules](#firestore-security-rules) for the per-field immutability matrix. + +### The `couples` document model + +```text +/couples/{coupleId} + id: string + userIds: [string, string] + inviteCode: string + createdAt: timestamp (server-side) + streakCount: int + lastAnsweredAt: timestamp | null + currentQuestionId: string | null # server-controlled, read by clients + activePackId: string | null # server-controlled, read by clients + encryptionVersion: int # 0 plaintext, 1 migrating, 2 strict + wrappedCoupleKey: string | null + kdfSalt: string | null + kdfParams: string | null + encryptionMigrationUsers: map +``` + +`currentQuestionId` and `activePackId` exist as fields and are read by clients to display "today's question" state, but they are server-controlled — clients cannot write them. + +### Rate limiting on accept + +`functions/src/couples/acceptInviteCallable.ts` enforces a rolling-window rate limit: + +- **Window**: 1 hour. +- **Max attempts per caller**: 10. +- **Storage**: `users/{uid}/invite_attempts` with a Firestore TTL field (`expiresAt`, 25 hours) so old attempts age out automatically. +- **Index**: TTL field override is declared in `firestore.indexes.json` under `fieldOverrides` for the `invite_attempts` collection group. + +This prevents brute-forcing the 6-character invite code space. + +### Recovery phrase flow + +The recovery phrase is the only human-readable secret in the system. It is never sent to the server in plaintext. + +1. When an Android inviter creates a couple, `RecoveryKeyManager.generateRecoveryPhrase()` produces a 10-word phrase from a 256-word list. The phrase has roughly 80 bits of raw entropy; Argon2id makes brute-force infeasible. +2. The inviter encrypts the phrase with the invite code using `encryptPhraseWithCode` and stores the blob on the invite document. +3. The acceptor receives the encrypted blob, decrypts it with the same code, and stores the phrase locally. +4. The phrase is used to unwrap the couple keyset from `wrappedCoupleKey`. +5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore. + +iOS does not generate or store a recovery phrase in the current build. iOS couples have no recovery path; the couple key (when iOS E2EE ships) will need a different recovery story or the gap will need to be communicated to users. + +### Key Android files + +- `app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt` — Firebase Auth wrapper. +- `app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt` — callable wrappers for invite create/accept. +- `app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt` — invite business logic, code retry, phrase encryption/decryption. +- `app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt` — keyset orchestration. +- `app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt` — Argon2id KDF, phrase generation, wrap/unwrap. +- `app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt` and `AcceptInviteViewModel.kt` — UI layer. + +### Key Cloud Functions + +- `functions/src/couples/createInviteCallable.ts` +- `functions/src/couples/acceptInviteCallable.ts` + +--- + +## End-to-end encryption model + +### Encryption versions + +`couples/{coupleId}.encryptionVersion` is the single source of truth for a couple's encryption state. The mapping is canonical in `app/src/main/java/app/closer/crypto/EncryptionVersion.kt` and mirrored in Cloud Functions. + +| 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. | + +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. + +### Couple key wrapping with Argon2id + +The couple keyset is a Tink AES-256-GCM keyset generated once per couple. + +- `RecoveryKeyManager.newCoupleKeyset()` creates the keyset. +- `RecoveryKeyManager.wrap(keyset, phrase)` derives a 32-byte key with Argon2id: + - **memory**: 46 MiB (`46080` KiB) + - **iterations**: 3 + - **parallelism**: 1 + - **salt**: 16 random bytes +- The keyset plaintext is encrypted with AES-256-GCM using the derived key. AAD is the fixed string `"closer_couple_key"` so the blob is portable across invite-code reconciliation. +- The wrapped result is stored on the couple document as `wrappedCoupleKey`, `kdfSalt`, `kdfParams`. + +The Argon2id parameters are deliberately chosen to take ~2-3 seconds on a mid-range phone — slow enough to make offline brute-force infeasible, fast enough that recovery on a new device is bearable. Do not change these parameters without auditing cross-platform compatibility. + +### Tink AEAD + +- `FieldEncryptor.kt` encrypts individual Firestore fields. Wire format: `enc:v1:{base64(tinkCiphertext)}`. AAD is the `coupleId`. +- `SealedAnswerEncryptor.kt` encrypts sealed answer payloads. Wire format: `sealed:v1:{urlsafe-base64-no-padding}`. AAD is `coupleId|questionId|userId`. +- `CoupleKeyStore.kt` persists Tink keyset handles in Keystore-backed EncryptedSharedPreferences. + +### Recovery phrase + +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 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. + +Flow: + +```text +1. User composes answer. +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 answers exist, each app: + a. Reads partner's public key from users/{partnerId}/devices/primary. + b. Wraps its own one-time key to partner's public key with ECIES P-256. + c. Writes keybox to releaseKeys/{partnerId}. +8. Each app reads the keybox written for them, unwraps with their private key, + and decrypts the partner's sealed payload. +``` + +Wire formats: + +| Field | Format | Where stored | +| --- | --- | --- | +| Sealed payload | `sealed:v1:{urlsafe-base64-no-padding}` | `answers/{userId}.encryptedPayload` | +| Commitment hash | `sha256:{urlsafe-base64-no-padding}` (43 chars) | `answers/{userId}.commitmentHash` | +| Keybox | `keybox:v1:{urlsafe-base64-no-padding}` (120+ chars) | `answers/{userId}/releaseKeys/{recipientId}.encryptedAnswerKey` | +| Public key | `pub:v1:{urlsafe-base64-no-padding}` | `users/{uid}/devices/primary.publicKey` | + +The commitment hash lets the reveal step verify that the decrypted payload matches what was originally sealed. If a malicious server (or a future bug) tampers with `encryptedPayload` and re-seals it with a new key, the commitment check fails at reveal time. + +### ECIES P-256 details + +- `UserKeyManager.kt` generates a per-user ECIES P-256 keypair using Tink's `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` template. +- The private key is stored in EncryptedSharedPreferences. The public key is extracted and published to Firestore. +- `ReleaseKeyEncryptor.kt` wraps a one-time answer key to the recipient's public key. +- Context info for ECIES: `coupleId|questionId|senderUserId|recipientUserId`. This binds the wrapped key to a specific origin and destination. + +### Known limitation: single-device keys + +`UserKeyManager.kt` documents a known limitation: there is **one keypair per user, stored only on the device that created it**. If a user signs in on a second device and generates a new keypair, sealed answers whose keys were wrapped for the old public key become undecryptable. The fix path is multi-device key distribution, but it is not implemented. **Do not market multi-device support** until this is resolved. + +### Firestore rules regex helpers + +The security rules validate E2EE wire formats using regex helpers. These helpers are the contract — any client writing sealed answers must match them exactly. + +```text +isCiphertext(value) → ^enc:v1:[A-Za-z0-9+/]+={0,2}$ +isSealedPayload(value) → ^sealed:v1:[A-Za-z0-9_-]{80,}$ +isKeybox(value) → ^keybox:v1:[A-Za-z0-9_-]{120,}$ +isCommitmentHash(value) → ^sha256:[A-Za-z0-9_-]{43}$ +``` + +Bumping the version prefix (e.g. `sealed:v1:` → `sealed:v2:`) is a wire-format break. Plan migration carefully. + +### Key Android crypto files + +- `app/src/main/java/app/closer/crypto/EncryptionVersion.kt` — canonical version constants. +- `app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt` — Argon2id, phrase generation, couple key wrap/unwrap. +- `app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt` — orchestration. +- `app/src/main/java/app/closer/crypto/CoupleKeyStore.kt` — local secure storage for keysets. +- `app/src/main/java/app/closer/crypto/FieldEncryptor.kt` — `enc:v1:` field encryption. +- `app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt` — sealed payloads. +- `app/src/main/java/app/closer/crypto/SealedRevealManager.kt` — release-key flow orchestration. +- `app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt` — ECIES wrapping of one-time keys. +- `app/src/main/java/app/closer/crypto/UserKeyManager.kt` — per-user ECIES keypair lifecycle. +- `app/src/main/java/app/closer/crypto/AnswerCommitment.kt` — canonical JSON + SHA-256. +- `app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt` — local store for one-time keys awaiting partner reveal. + +--- + +## Daily question lifecycle + +### Assignment + +`assignDailyQuestion` is a scheduled Pub/Sub function: + +- **Schedule**: `0 23 * * *` in `America/Chicago` timezone. +- **What it does**: picks a random active free question from the `questions` Firestore collection, then writes a `daily_question/{date}` document under each couple. +- **Document path**: `couples/{coupleId}/daily_question/{YYYY-MM-DD}`. +- **Idempotency**: uses `docRef.create({...})` and catches `ALREADY_EXISTS` so a re-run on the same day is a no-op. +- **Document shape**: + ```text + couples/{coupleId}/daily_question/{date} + questionId: string + date: string (YYYY-MM-DD) + assignedAt: Timestamp + expiresAt: Timestamp + ``` + +There is also `assignDailyQuestionCallable` for manual / on-demand assignment (used when a couple is created mid-day and shouldn't wait for the next scheduled run). + +### Date math — known DST bug + +The function uses `CST_OFFSET_HOURS = -6` and does not account for daylight saving time. The actual UTC offset for `America/Chicago` is -5 (CDT) in summer and -6 (CST) in winter. This means: + +- 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`. + +### Answer write + +Answers are written by the client under `couples/{coupleId}/daily_question/{date}/answers/{userId}`. The Firestore rules require the document to match one of two shapes: + +1. **Legacy / company-proof** (any `schemaVersion` ≠ 3): `enc:v1:` ciphertext fields, content is encrypted with the couple key. +2. **Sealed / partner-proof** (`schemaVersion = 3`): `sealed:v1:` payload + `sha256:` commitment; content key is released only after both partners have submitted. + +A write must match exactly one of these shapes — the rules reject anything else. + +### Partner notification + +`functions/src/questions/onAnswerWritten.ts` is a Firestore trigger on `couples/{coupleId}/daily_question/{date}/answers/{userId}` (onCreate). It looks up the partner's FCM tokens and sends: + +- A data message so the client can route directly to the reveal screen. +- A notification block for system-tray display when the app is in the background. + +Token lookup reads both a legacy `fcmToken` field on the user doc and a dedicated `fcmTokens` subcollection for multi-device. + +### Reveal flow + +The reveal happens client-side after both partners have submitted: + +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. + +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. + +### Thread questions + +Thread questions follow the same sealed flow but use a different path: + +- `couples/{coupleId}/threads/{threadId}/messages/{userId}` — the thread message, not the daily answer. +- The Firestore rules use `isSealedThreadAnswerCreate` / `isSealedThreadAnswerUpdate` helpers, which are identical to the answer helpers except there is no `answerDate` and no `isRevealed` field (reveal state is tracked by the thread VM, not the rules). + +--- + +## Firestore data model + +```text +/users/{uid} + email: string + displayName: string + photoUrl: string | null + coupleId: string | null + hasPremium: bool # server-only write + platform: 'android' | 'ios' | null + /entitlements/premium # written by Cloud Functions only + premium: bool + expiresAt: Timestamp | null + /fcmTokens/{tokenId} # owned by the user + token: string + platform: 'android' | 'ios' + updatedAt: Timestamp + /devices/{deviceId} # ECIES public keys for sealed answers + publicKey: string # 'pub:v1:...' + platform: 'android' | 'ios' + updatedAt: Timestamp + /outcomes/{dayKey} # day_0, day_30, day_60, day_90; server-only + submittedAt: Timestamp + answers: map + /notification_queue/{id} # server-only; partner-pending notifications + type: string + payload: map + createdAt: Timestamp + delivered: bool + /invite_attempts/{id} # rate-limit; Firestore TTL + code: string + attemptedAt: Timestamp + expiresAt: Timestamp # TTL field + +/couples/{coupleId} + id, userIds[2], inviteCode, createdAt + streakCount, lastAnsweredAt + currentQuestionId, activePackId + encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams + encryptionMigrationUsers: map + /daily_question/{YYYY-MM-DD} + questionId, date, assignedAt, expiresAt + /answers/{userId} # schemaVersion 2 (enc:v1:) or 3 (sealed:v1:) + /releaseKeys/{recipientId} # keybox:v1: + /threads/{threadId} + questionId, createdAt, createdByUserId + /messages/{userId} # schemaVersion 3 sealed + /this_or_that/{sessionId} # games: enc:v1: shared couple key + /desire_sync/{sessionId} + /how_well/{sessionId} + /wheel/{sessionId} + /date_swipes/{swipeId} + userId, dateIdeaId, action: 'love' | 'maybe' | 'skip' + /date_matches/{matchId} + userIds, dateIdeaId, createdAt + /date_plans/{planId} + title, dateTime, status: 'draft' | 'planned' | 'completed' + /date_plan_preferences/{uid} + categories: map + /bucket_list/{itemId} + title, category, addedByUserId, completedAt + +/invites/{code} # server-only writes; 24h TTL + code, inviterUserId, status: 'pending' | 'accepted' | 'expired' + createdAt, expiresAt + wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase + +/questions/{questionId} # read-only catalog (admin seeded) + text, categoryId, active, isPremium + +/date_ideas/{dateIdeaId} # read-only catalog + title, description, category, imageUrl + +/entitlement_events/{eventId} # server-only; idempotency markers + userId, type, source, processedAt +``` + +### Cross-references + +- `users/{uid}.coupleId` → `couples/{coupleId}`. +- `couples/{coupleId}.userIds` → `users/{uid}` (the two members). +- `couples/{coupleId}/daily_question/{date}/answers/{userId}.userId` → `users/{uid}`. +- `couples/{coupleId}/date_swipes/{swipeId}.userId` → `users/{uid}`. +- `entitlement_events/{eventId}.userId` → `users/{uid}`. + +--- + +## Firestore security rules + +`firestore.rules` is the single source of truth for client authorization. Admin SDK / Cloud Functions bypass these rules, so anything that must be server-only is denied for direct client writes. + +### Helper functions + +The rules file is organized into helper functions first, then per-collection match blocks. + +```text +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}$') +isImmutable(fields) diff(...).affectedKeys().hasOnly(fields) +isCiphertext(value) matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$') +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 +isUpdatingRecoveryWrap() only wrappedCoupleKey/kdfSalt/kdfParams +isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt +``` + +### Per-collection enforcement + +**`users/{uid}`** — owner can read/create/update their own doc but `hasPremium` is server-only. `entitlements/`, `notification_queue/`, and `outcomes/` are server-only writes. `fcmTokens/` and `devices/` are owner-writable. The `devices/` public key is readable by the user's current couple partner only (to wrap release keys) — restricting it prevents speculative pre-encryption by non-partners. + +**`date_ideas/`** — read-only for any signed-in user; writes are admin-only. + +**`invites/{code}`** — reads are restricted to the inviter (`request.auth.uid == resource.data.inviterUserId`). All writes are denied for clients. This is the core defense against 6-character code enumeration: even legitimate create/update/delete must go through a Cloud Function, which can enforce rate limits, uniqueness, and key-material checks. + +**`couples/{coupleId}`** — only the two members may read. Writes are denied for clients entirely; the rules restrict the shape of the doc and let Cloud Functions do all updates. Field-level immutability helpers (`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`, `isStartingEncryptionMigration`, `isCompletingOwnEncryptionMigration`) define what each update path is allowed to touch. + +**`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}/releaseKeys/{recipientId}`** — create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced. + +**`couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel}/{sessionId}`** — `enc:v1:` ciphertext per user. Games are company-proof (server can't read), but not partner-proof (a modified client could read the partner's encrypted slot before the reveal). Sealed per-answer keys are not used here because games are real-time simultaneous — both players submit and see results together. + +**`entitlement_events/`** — no client access. + +### Why invariants matter + +The rules are not just access control — they are a wire-format contract. A client that writes a malformed sealed payload is denied at write time, which prevents bad data from propagating. Bumping a wire format version (e.g. `sealed:v1:` → `sealed:v2:`) is a rules change AND a client change; do them together. + +--- + +## Cloud Functions + +### Module pattern + +Every function module follows the same shape: + +- One or more exported handlers (callable, onRequest, onCall trigger, onCreate trigger, Pub/Sub schedule). +- Lazy `admin.firestore()` / `admin.messaging()` access at invocation time. The Admin SDK is initialized once in `functions/src/index.ts`. +- The function name is the export name. `index.ts` re-exports each handler explicitly — no glob imports. +- Cloud Function logs are prefixed with the function name (e.g. `[acceptInviteCallable]`) for grep-ability. + +### Handler types + +| Type | Example | Notes | +| --- | --- | --- | +| HTTPS onRequest | `revenueCatWebhook`, `health` | Path-based; bypass callable auth. Webhook requires Ed25519 signature verification. | +| HTTPS onCall | `createInviteCallable`, `acceptInviteCallable`, `syncEntitlement`, `sendDailyQuestionReminder`, `sendPartnerAnsweredNotification`, `sendGentleReminderCallable`, `submitOutcomeCallable`, `leaveCoupleCallable`, `checkDeviceIntegrity`, `assignDailyQuestionCallable` | Caller must be authenticated. Errors throw `HttpsError`. | +| Firestore onCreate | `onAnswerWritten`, `onMessageWritten`, `onCoupleLeave`, `onUserDelete`, `onGameSessionUpdate`, `createDateMatchOnMutualLove` | Event-driven; best-effort. | +| Auth onDelete | `onUserDelete` | Auth user deletion cascade. | +| Pub/Sub schedule | `assignDailyQuestion`, `scheduledOutcomesReminder` | Cron expression in `America/Chicago`. | + +### Per-module responsibilities + +- **billing** — RevenueCat webhook, entitlement event handlers, forced re-sync callable. Entitlement writes are idempotent (write the same Firestore doc and use `entitlement_events/` as a dedup marker). +- **couples** — invite create/accept/leave, outcome submission, scheduled 30/60/90 reminders. +- **dates** — mutual-love trigger creates a date match document. +- **games** — game session updates notify the partner and append to `notification_queue`. +- **notifications** — daily question reminders, partner-answered notifications, gentle reminders, challenge day reminders, capsule unlock schedule. +- **questions** — daily question assignment, answer write trigger, thread message trigger. +- **security** — Play Integrity verdict verification. +- **users** — Auth user deletion cascade. + +### Webhook reliability + +`revenueCatWebhook` acknowledges with HTTP 200 immediately after signature verification and parses the event, then **before** applying the entitlement write. This is intentional to prevent RevenueCat retries. If `applyEntitlementEvent` fails after the 200, the failure is logged but the event is not retried. The webhook handler does not currently use a dead-letter queue. **Risk**: a transient Firestore outage could lose entitlement events. The mitigation today is `entitlement_events/{eventId}` as an idempotency marker — re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection. + +### Schedule + +```text +assignDailyQuestion 0 23 * * * America/Chicago +scheduledOutcomesReminder * * * * * America/Chicago (per-minute, scans couples) +unlockDueMemoryCapsules (in gameRetention; check source for cron) +sendChallengeDayReminders (in gameRetention; check source for cron) +``` + +`scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows. + +--- + +## Billing + +### RevenueCat integration + +Both clients use the RevenueCat native SDK (`purchases:8.20.0` on Android, `purchases-ios` via SPM). The SDK handles the platform billing surface (Google Play Billing on Android, StoreKit on iOS) and exposes a normalized `CustomerInfo` API. + +The Android app reads the API key from `BuildConfig.RC_API_KEY`, which is sourced from `local.properties` or the `RC_API_KEY` env var. A release build with a missing or placeholder key **fails fast** with a `GradleException` from `app/build.gradle.kts` — there is a doFirst guard on every release assemble/bundle task. + +iOS reads `RC_API_KEY` from `Info.plist` via the `Secrets` enum in `CloserApp.swift`. A missing or empty key logs a warning and the app continues, but the paywall will not be functional. + +### Server-verified entitlements + +The Android `FirestoreEntitlementChecker` is the source of truth for premium state. It: + +1. Observes `users/{userId}/entitlements/premium` for real-time changes. +2. If the server document does not exist, falls back to the local RevenueCat `CustomerInfo`. +3. Treats an `expiresAt` in the past as `premium = false`. +4. **Fails closed**: on Firestore listener error, `isPremium()` emits `false` (i.e. "not premium") rather than guessing. + +The iOS `DefaultEntitlementChecker` actor does **not** observe Firestore entitlements. It reads RevenueCat `CustomerInfo` only, via `Purchases.shared.customerInfoStream`. **iOS premium state is therefore client-verified, not server-verified** — this is a known gap that should be closed before production. + +The `EntitlementChecker` interface (Android) is intentionally narrow: + +```kotlin +interface EntitlementChecker { + fun isPremium(): Flow + suspend fun hasPremium(): Boolean + fun onCustomerInfoUpdated(customerInfo: CustomerInfo) +} +``` + +Callers collect `isPremium()` reactively rather than caching a one-time snapshot. After a successful purchase, the Android repository calls `onCustomerInfoUpdated(...)` to push the new `CustomerInfo` into the fallback so the next emit reflects it. + +### Webhook + +`functions/src/billing/revenueCatWebhook.ts`: + +- **Path**: HTTPS, POST only. GETs return 405. +- **Auth**: Ed25519 signature verification. `REVENUECAT_SIGNING_KEY` env var holds the base64-encoded DER/SPKI public key. Missing key → 500 (config error). Invalid/missing signature → 401. +- **Body**: RevenueCat event payload. Malformed payload → 400. +- **Flow**: acknowledge 200 immediately, then call `applyEntitlementEvent(event)`. +- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped. + +--- + +## Notifications + +### FCM + +Both clients use the Firebase Messaging SDK. Android uses FirebaseMessagingService; iOS uses APNs + FCM bridge. Tokens are stored under `users/{uid}/fcmTokens/{tokenId}` with platform metadata. + +### TokenRegistrar (Android) + +`TokenRegistrar` runs in the Android `core/notifications/` module. On token refresh it writes to Firestore with the current device's platform, device ID, and timestamp. The trigger function `onAnswerWritten` reads both a legacy `fcmToken` field and the `fcmTokens` subcollection for fan-out. + +### Quiet hours + +`QuietHours` is a `DataStore`-persisted data class in `SettingsRepository`. It suppresses non-critical notifications during a configured window. **Server-side quiet-hour suppression is not implemented today** — all suppression happens on the client. The push is still delivered, the client decides whether to display it. This is a known gap; if battery/UX becomes a problem, the suppression should move to the server. + +### Daily question reminders + +`sendDailyQuestionReminder` (callable) and `assignDailyQuestion` (scheduled) handle the two reminder paths. The scheduled function writes a new `daily_question/{date}` doc; the callable is used for manual re-triggering. + +### Partner-answered notification + +`functions/src/questions/onAnswerWritten.ts` is a Firestore onCreate trigger on `couples/{coupleId}/daily_question/{date}/answers/{userId}`. It looks up the partner's tokens and sends an FCM with a data payload (routing to the reveal screen) and a notification block (system-tray display). + +### Gentle reminders and challenges + +`sendGentleReminderCallable` and `sendChallengeDayReminders` (in `gameRetention.ts`) handle ad-hoc nudges. `unlockDueMemoryCapsules` opens time capsules whose lock date has passed. + +### Per-user notification_queue + +`users/{uid}/notification_queue/` is a server-only collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is for in-app polling and for tracking delivery. Reads are denied for clients; the app reacts to FCM, not the queue. + +--- + +## iOS-specific notes + +### Architecture + +- `@main` is in `CloserApp.swift`. `AppState` is the root `@StateObject`; views receive it via `@EnvironmentObject`. +- `AppState` owns `authState`, `currentUser`, `currentCouple`, `currentPartner`, and `isPremium`. It listens to auth state and partner changes. +- The app uses `NavigationStack` + `.navigationDestination` for routing. There is no DI framework; dependencies are passed through `shared` singletons (`AuthService.shared`, `FirestoreService.shared`, `BillingService.shared`). +- iOS 17+ is the deployment target. + +### CloserTheme + +`CloserTheme.swift` defines color tokens, typography, spacing, and radius. The brand is documented in `docs/brand/visual-identity.md` and copy is in `docs/copy-guide.md`. Brand color references use `Color.closerPrimary`, `Color.closerBackground`, etc. (not raw hex). + +### XcodeGen + +The iOS project is generated by XcodeGen from `iphone/project.yml`. After editing `project.yml`, run `xcodegen generate` and reopen `Closer.xcodeproj`. Do not commit changes to `Closer.xcodeproj` — it is regenerated. + +### Build + +```bash +cd iphone +xcodegen generate +xed Closer.xcodeproj +# or +xcodebuild -project iphone/Closer.xcodeproj \ + -scheme Closer \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + build +``` + +### iOS E2EE gap + +The iOS port does not implement E2EE today. Concrete consequences: + +- iOS couples are created with `encryptionVersion = 0`. Their `couples` doc has no `wrappedCoupleKey` / `kdfSalt` / `kdfParams`. +- iOS answer writes use the plaintext path. Firestore rules allow plaintext only when `encryptionVersion < 1`. +- iOS does not generate or store a recovery phrase. +- iOS does not have a CryptoKit implementation of Tink keyset serialization, Argon2id KDF, or sealed-answer ECIES. +- iOS premium state is RevenueCat-only, not server-verified. + +A user who pairs an Android device with an iOS device today creates a mixed-couple (`encryptionVersion = 2` from Android, but the iOS partner's flow is still plaintext). The Android-side rules tolerate this, but the Android user is the only one whose answers are actually encrypted — the iOS user's answers are plaintext. **Do not ship this combination to users** without: + +1. A CryptoKit E2EE implementation that produces byte-compatible ciphertexts with the Android Tink paths. +2. An Argon2id implementation that produces identical bytes for the recovery phrase KDF (e.g. `SwiftArgon2` with the exact same parameters: `m=46080 KiB, t=3, p=1, salt=16 bytes`). +3. Server-side gating that refuses to pair cross-platform couples until iOS has parity, OR an explicit user-facing "iOS answers are not yet encrypted" notice. + +### iOS CryptoKit guidance (future) + +When implementing iOS E2EE parity: + +- Use CryptoKit's `AES.GCM` for symmetric encryption. AAD binding must match Android exactly. +- Use `P256.KeyAgreement` + `HKDF` + `AES.GCM` for ECIES equivalent. Tink's `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` is not bit-compatible with raw CryptoKit, so this is a non-trivial port. Consider keeping Tink via `BoringSSL-TLC` or porting the exact KDF/AEAD composition. +- Use `SecItemAdd` / `SecItemCopyMatching` for keychain storage. Replace EncryptedSharedPreferences with Keychain in a way that survives app reinstalls on the same device. +- For Argon2id, the open-source `SwiftArgon2` package is the only reasonable option. Verify byte output against the Android BouncyCastle reference before shipping. + +--- + +## Build and release + +### Android + +- **Module**: `app/` +- **Package**: `app.closer` +- **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35 +- **Java/Kotlin**: 17 +- **Versioning**: `versionCode` is integer; `versionName` is a string. **Current state**: `versionCode = 1`, `versionName = "0.1.0"`. HISTORY.md describes versions up to `0.2.x`. Bump `versionName` in `app/build.gradle.kts` when cutting a release. + +### Required build secrets + +- `RC_API_KEY` — RevenueCat public SDK key, sourced from `local.properties` or env. Release builds fail without it. +- `google-services.json` — Firebase config. The repo template does not include a real one; copy from your Firebase project. +- `local.properties` — local-only, never committed. + +### Gradle config + +`app/build.gradle.kts` declares: + +- Compose BOM 2025.01.01 +- Hilt 2.53.1 +- Room 2.6.1 +- DataStore 1.1.2 +- Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions) +- RevenueCat 8.20.0 +- Tink 1.13.0, BouncyCastle 1.78.1 +- Play Integrity 1.4.0 +- Biometric 1.1.0 +- Credential Manager 1.3.0 + +### ProGuard + +`app/proguard-rules.pro` keeps Tink reflection paths and other crypto classes. Release builds run `minifyEnabled = true` and `shrinkResources = true`. Always smoke-test a release build before publishing — ProGuard rules for new libraries are easy to forget. + +### Common commands + +```bash +./gradlew :app:assembleDebug +./gradlew :app:installDebug +./gradlew :app:compileDebugKotlin # fast typecheck +./gradlew :app:assembleRelease # fails without RC_API_KEY +``` + +### Firebase Functions + +```bash +cd functions +npm install +npm run build # TypeScript → dist/ +npm run serve # local emulator +firebase deploy --only functions +``` + +`dist/` is committed so the deployed function code is reproducible without running `npm run build` at deploy time. + +### Optional Express server + +`server/` is an Express webhook/health service that is not client-facing. It exists for environments where Cloud Functions are not the right deployment surface (e.g. custom VPC). Most teams will not need it. + +--- + +## Engineering conventions + +### Git + +- **`dev` is the working branch. `main` is the stable/release branch.** All feature work happens on `dev`. +- **One commit per batch.** No bundling multiple batches into one commit. Each commit message has a clear scope (`feat(scope): description (batch X.Y.Z)`). +- **Push to `dev` after every commit.** Don't accumulate locally. +- **Forgejo remote**: `ssh://forgejo/null/Closer.git` (the repo was renamed from `relationship-app` to `Closer` in 2026). + +### Files that must never be committed + +Add to every clone's `.gitignore`: + +```text +FUTURE.md +HISTORY.md +PROJECT.md +STRUCTURE.md +project-requirements.md +DEVELOPMENT_LOG.md +BUILD_SUMMARY.md +SCRIPTS.md +.learnings/ +.kotlin/ +``` + +These are agent-only or workspace-only docs and have no place in the public repo. + +### Versioning + +- **Major**: 0 → 1 is the MVP-to-public cut. Today we are 0.x. +- **Minor**: a complete feature batch (e.g. E2EE parity, payment integration, full iOS port). +- **Patch**: bug fixes, polish, internal refactors. +- **Source of truth**: `app/build.gradle.kts` for Android `versionName`. HISTORY.md is the changelog. Keep them in sync. + +### Naming + +- Android: Kotlin package `app.closer.*` (do not resurrect the old `com.couplesconnect` package). +- iOS: Swift module `Closer`. Folder names match Android's screen names where they exist. +- Cloud Functions: one module per domain (`billing/`, `couples/`, ...). Function names match the file name (`acceptInviteCallable.ts` → `acceptInviteCallable`). + +### Logging + +- Cloud Functions prefix every log line with the function name: `[acceptInviteCallable] ...`. +- Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap `android.util.Log` calls in `BuildConfig.DEBUG` guards (see `app/src/main/java/app/closer/data/local/QuestionJsonParser.kt` for an example). +- Crashlytics is the production observability path. Do not log to both Crashlytics and console in production. + +### Error handling + +- Cloud Functions: throw `HttpsError(code, message)` with the closest matching code. Never throw a plain `Error`. +- Android: repositories return `Result` for fallible operations. ViewModels expose `StateFlow` with a sealed `Error` variant. +- iOS: `throws` for fallible paths; `AsyncStream` for reactive state. +- Offline behavior: question packs are bundled so the app is fully usable offline. Daily question assignment, partner reveal, and notifications all require network. + +### Testing + +- Android unit tests live in `app/src/test/`. JVM only; no device/emulator. +- Cloud Functions: `entitlementLogic.test.ts` uses Vitest. Run with `npm test` in `functions/`. +- Manual QA: see `docs/qa/private-mvp-checklist.md` and `docs/qa/ui-review.md`. + +### Privacy and data retention + +- Couples are deleted on user account deletion (cascade in `onUserDelete`). +- Invite attempts auto-expire via Firestore TTL (25h). +- Invites auto-expire after 24h. +- Notification queue entries are written by Cloud Functions and consumed by FCM; they are not auto-deleted today. Add a TTL if retention becomes a concern. + +--- + +## Where to look first + +If you are new to the codebase, read these files in order: + +1. **`README.md`** — product positioning and feature scope. +2. **`PROJECT.md`** — formal product spec. +3. **`app/src/main/java/app/closer/crypto/EncryptionVersion.kt`** — the encryption version contract. +4. **`firestore.rules`** — every client write goes through these. +5. **`functions/src/index.ts`** — every Cloud Function the project exposes. +6. **`functions/src/couples/acceptInviteCallable.ts`** — the most representative callable. Pair creation, rate limiting, E2EE fields, recovery phrase wipe, encryptionVersion derivation all in one file. +7. **`functions/src/questions/assignDailyQuestion.ts`** — the daily question scheduled function with the DST-quirky date math. +8. **`app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt`** and **`SealedRevealManager.kt`** — sealed-answer wire format and reveal flow. +9. **`app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`** — the client-side reveal state machine. +10. **`app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt`** — server-verified entitlement flow. +11. **`iphone/Closer/Services/FirestoreService.swift`** — the iOS side of cross-platform data contracts. +12. **`iphone/ARCHITECTURE_AUDIT.md`** — generated iOS port blueprint. + +If you are working on a specific area, the relevant section in this manual points to the key files for that area.