Closer/docs/Engineering_Reference_Manua...

143 KiB
Raw Blame History

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


Table of Contents

  1. System overview
  2. Repository layout
  3. Authentication and pairing flow
  4. End-to-end encryption model
  5. Daily question lifecycle
  6. Firestore data model
  7. Firestore security rules
  8. Cloud Functions
  9. Billing
  10. Notifications
  11. iOS-specific notes
  12. Build and release
  13. Engineering conventions
  14. Known landmines and recent fixes
  15. 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 code-complete for schemaVersion 2 (couple-key) - see iOS E2EE gap
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.

Couple-shared premium

A subscription is per couple, not per user. The Firestore rules for users/{uid}/entitlements/premium allow the partner to read the user's premium state (in addition to the owner), so premium-gated features unlock if either partner is subscribed. The check that the actual feature unlocks lives in FirestoreEntitlementChecker and CouplePremiumChecker. iOS does not currently observe Firestore entitlements - its premium state is RevenueCat-only (see Server-verified entitlements).

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

app/src/main/java/app/closer/
├── MainActivity.kt
├── analytics/           # RetentionEvent + RetentionAnalytics (separate from core/analytics; used for product funnel)
├── widget/              # Glance Today home-screen widget (R29)
├── core/
│   ├── analytics/       # Firebase Analytics + Crashlytics wrappers
│   ├── billing/         # EntitlementChecker + FirestoreEntitlementChecker + CouplePremiumChecker
│   ├── crash/           # CrashReporter abstraction
│   ├── feature/         # (reserved for feature flags; no directory exists today - no feature-flag code in repo)
│   ├── firebase/        # FirebaseInitializer (manual App Check / init path)
│   ├── media/           # MediaCompressor (image/video compression before encrypted upload)
│   ├── navigation/      # AppRoute constants, NavHost, ExternalLinks
│   └── notifications/   # AppMessagingService, QuietHours, TokenRegistrar
├── crypto/              # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
├── data/
│   ├── backup/          # BackupManager + BackupRestoreManager + RestoreManager (R24 E2EE backup + partner-assist restore)
│   ├── challenges/      # Connection Challenges data sources
│   ├── local/           # Room DAOs, DataStore, EncryptedSharedPreferences (RecoveryPhraseStore, SecurePreferencesFactory, SettingsDataStore); also converters/, entity/, mapper/ subdirs
│   ├── (no `questions/` package — JSON parsing was retired; see note below)
│   ├── remote/          # Firestore data sources, Cloud Functions callable wrappers
│   ├── repository/      # Repository implementations
│   └── security/        # PlayIntegrityChecker
├── di/                  # Hilt modules
├── domain/
│   ├── model/           # Plain data classes
│   ├── repository/      # Repository interfaces
│   ├── security/        # AuthRateLimiter, DeviceIntegrityChecker (interface for PlayIntegrityChecker)
│   └── usecase/         # Cross-VM use cases: DailyQuestionResolver, GameSessionManager, SoloAnswerMigrator
├── notifications/       # Notification surfaces: ActiveGameSessionMonitor, GamePromptController, PartnerNotificationManager, QuietHoursManager, NotificationChannelSetup, NotificationRateLimiter, MessageBubbleController, ActiveThreadMonitor
└── ui/                  # Compose screens + ViewModels
    ├── activity/        # Together / Activity screen
    ├── answers/         # Answer write/reveal/history
    ├── auth/            # Login, signup
    ├── brand/           # Logo, splash, illustrated empty states
    ├── challenges/      # Connection Challenges
    ├── components/      # Shared Compose components (GamePromptBanner, EmptyState, BrandIllustration, etc.)
    ├── dates/           # Date builder, matches, bucket list
    ├── debug/           # Debug-only screens (QA tooling)
    ├── desiresync/      # Preferences alignment exercise
    ├── games/           # Game scaffolding
    ├── home/            # Home dashboard + partner state
    ├── howwell/         # How Well Do You Know Me game
    ├── memorylane/      # Time capsules
    ├── messages/        # E2E chat (ConversationScreen, MessagesInboxScreen) + components/
    ├── 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 + components/
    ├── recap/           # Weekly recap screen + share card
    ├── settings/        # All settings screens (account, privacy, security, subscription, …)
    ├── theme/           # CloserTheme
    ├── thisorthat/      # This or That game
    └── wheel/           # Spin the wheel

Note on the manual's older description: a core/security/ package was documented in earlier revisions of this manual but doesn't exist in the current source. The auth rate limiter is in domain/security/AuthRateLimiter.kt, and question content is loaded from the bundled Room/asset-DB (QuestionDao in data/local/QuestionDao.kt) — the old data/questions/QuestionJsonParser.kt was retired by commit 21392ec2 (Tier 1 dead-file sweep, 2026-07-09). The core/notifications/ package no longer contains NotificationHelper.kt or NotificationPermissionHelper.kt (both removed by the same sweep as dead duplicates of NotificationChannelSetup); the notifications/ package no longer contains PartnerNotificationScheduler.kt (superseded by PartnerNotificationManager); and the domain/model/ package no longer contains Entitlement.kt, InviteStatus.kt, or QuestionSessionStatus.kt (entitlement state is read as Firestore booleans, not a domain model). All removals were verified with symbol-level git-grep + a clean :app:compileDebugKotlin + compileDebugUnitTestKotlin.

The Android settings package contains: SettingsScreen, SettingsViewModel, SettingsVisuals, AccountScreen, EditProfileScreen + EditProfileViewModel, AppearanceScreen, DeleteAccountScreen, NotificationSettingsScreen, PrivacyScreen, RelationshipSettingsScreen, SecurityScreen, SubscriptionScreen. The SecurityScreen is biometric-gated for the recovery phrase reveal.

The app/src/main/res/drawable-nodpi/ folder holds brand illustrations (onboarding, invite, paywall, subscription, history).

iOS

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 - gitignored, copy from your project
└── Closer/
    ├── CloserApp.swift             # @main (struct CloserApp + AppDelegate adaptor), AppState, RevenueCat init
    ├── Core/
    │   ├── Auth/AuthService.swift
    │   ├── Billing/BillingService.swift
    │   └── Notifications/NotificationService.swift
    ├── Crypto/                     # CryptoKit E2EE parity (R24+) - see iphone/Closer/Crypto/SPEC.md for the wire format
    │   # AnswerCrypto.swift, CoupleEncryptionManager.swift, CoupleKeyStore.swift, DeviceKeyStatus.swift,
    │   # FieldEncryptor.swift, Keybox.swift, RecoveryKeyManager.swift, SealedAnswer.swift, Wordlist.swift
    │   # Resources/ (248-word recovery phrase wordlist, verbatim copy of Android's)
    │   # SCHEMA_VERSION_DECISION.md, SPEC.md
    ├── Models/                     # Codable Firestore + domain types
    ├── Services/FirestoreService.swift  # Firestore + callable wrappers
    ├── Theme/CloserTheme.swift     # Colors, typography, spacing
    ├── Components/                 # Shared SwiftUI components
    ├── Navigation/                 # ContentView.swift - Root NavigationStack + TabView
    ├── 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 screen is rendered from Settings
    └── Resources/                  # Illustrations, assets

The iOS Crypto/ folder holds the E2EE parity code shipped across R24 batches 1-8 (see IOS_E2EE_STATUS.md and iphone/Closer/Crypto/SPEC.md for the wire format). Android+iOS pairing now works for the schemaVersion 2 (couple-key) daily-answer path; the schemaVersion 3 sealed-answer path is infrastructure-gated (paired-CI vector run + macOS end-to-end) and the iOS↔iOS path uses the native Path A envelope. See iOS E2EE gap for the precise status and known gaps.

Paywall/ is currently a placeholder; the actual paywall screen is rendered from Settings/SettingsViews.swift. A dedicated paywall view is a future cleanup.

Cloud Functions

functions/src/
├── options.ts                        # v2 setGlobalOptions (region, maxInstances, cpu, concurrency) - imported FIRST
├── log.ts                            # Shared `logger` + `redactToken` (B6d)
├── index.ts                          # Admin SDK init + explicit re-exports (no glob imports)
├── billing/
│   ├── revenueCatWebhook.ts          # HTTPS webhook - Ed25519 signature verify (NOT exported / not deployed - see Billing)
│   ├── entitlementLogic.ts           # Idempotent entitlement event handlers
│   ├── entitlementLogic.test.ts      # Vitest unit tests
│   ├── syncEntitlement.ts            # Callable - forced re-sync from client
│   └── onEntitlementChanged.ts       # users/{uid}/entitlements/premium onWrite → partner-facing push
├── couples/
│   ├── createInviteCallable.ts       # Server-side invite creation
│   ├── acceptInviteCallable.ts       # Code validation, couple creation, rate limit, unconditional encryptionVersion = 2
│   ├── 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
│   ├── aggregateOutcomes.ts          # Daily rollup → privacy-safe `aggregate_stats/` (server-only)
│   └── aggregateOutcomes.test.ts
├── dates/
│   ├── createDateMatch.ts            # Mutual-love → date match + notifyOnDateMatch
│   ├── onDateHistoryCreated.ts       # Date history aggregation trigger
│   ├── onDateReflectionWritten.ts    # Date reflection written trigger
│   └── onDateReflectionRevealed.ts   # Date reflection revealed trigger
├── games/
│   └── onGameSessionUpdate.ts        # Co-located: onGameSessionUpdate + per-game part-finished triggers
│                                     # (onThisOrThatPartFinished, onWheelPartFinished,
│                                     #  onHowWellPartFinished, onDesireSyncPartFinished)
├── notifications/
│   ├── sendGentleReminderCallable.ts # Manual gentle reminder
│   ├── sendThinkingOfYouCallable.ts  # Manual "thinking of you" nudge
│   ├── push.ts                       # ONE canonical FCM sender + getUserTokens() reader (B6d)
│   ├── quietHours.ts                 # `recipientInQuietHours(userData)` - fail-open
│   ├── quietHours.test.ts
│   ├── idempotency.ts                # `claimOnce(markRef)` - gRPC ALREADY_EXISTS dedupe
│   ├── idempotency.test.ts
│   ├── pruneTokens.ts                # `pruneDeadTokens()` - used by push.ts
│   ├── pruneTokens.test.ts
│   ├── time.ts                       # `chicagoDateKey`, `toMillis` - shared by scheduled jobs
│   ├── push.test.ts
│   ├── dailyQuestionReminder.ts      # sendDailyQuestionProactiveReminder (4 PM CT; deployed, idempotent)
│   ├── reengagement.ts               # sendReengagementReminder (noon CT; couples 3-10 days quiet)
│   ├── streakReminder.ts             # sendStreakReminder (7 PM CT; couples w/ active streak + no shared action today)
│   └── gameRetention.ts              # sendChallengeDayReminders + unlockDueMemoryCapsules
├── questions/
│   ├── assignDailyQuestion.ts        # Pub/Sub schedule + manual callable (server-authoritative, mode-aware, deterministic)
│   ├── assignDailyQuestion.test.ts
│   ├── onAnswerWritten.ts            # Trigger: notify partner on answer
│   ├── onAnswerRevealed.ts           # Trigger: notify partner when answers are opened
│   └── onMessageWritten.ts           # Trigger: thread messages
├── releaseKey/
│   └── wrapReleaseKeyCallable.ts     # iOS→Android sealed-answer Tink keybox bridge
├── backup/
│   └── onRestoreRequested.ts         # onRestoreRequested + onRestoreFulfilled (couple-key ECIES partner-assist)
│   └── onRestoreRequested.test.ts
├── security/
│   └── checkDeviceIntegrity.ts       # Play Integrity verdict verification
└── users/
    └── onUserDelete.ts               # Auth user deletion cascade (intentionally v1 - gen 2 has no auth.user().onDelete)

v2 migration (B0-B6d, 2026-07-08): every function except onUserDelete runs as 2nd gen in closer-app-22014 under a single shared set of global options (see functions/src/options.ts): region: us-central1 (pinned to match FirebaseFunctions.getInstance() on Android; do not change without pinning the client), maxInstances: 5 (runaway-bill guardrail + Cloud Run CPU-quota constraint), cpu: 'gcf_gen1' (gen1 fractional tiers, ~16 vCPU - chosen because the project's default regional Cloud Run CPU quota could not fit ~35 services at v2's default 1 vCPU/instance; the deploy container healthchecks failed with "Quota exceeded for total allowable CPU per project per region"), concurrency: 1 (required when cpu < 1). At launch: request the Cloud Run CPU quota increase, then drop cpu/concurrency to restore 1 vCPU + concurrency 80. onUserDelete is intentionally v1 because gen 2 has no auth.user().onDelete; pin its runtime in lockstep with the v2 ones.

Shared infrastructure (B6d): B6d collapsed ~10 copies of "read the user's tokens" and ~19 copies of "send to each token, log failures, prune the dead ones" into notifications/push.ts (getUserTokens, sendPushToUser, batchResponseToResults, redactToken) and notifications/idempotency.ts (claimOnce - gRPC ALREADY_EXISTS (6) dedupe, fail-OPEN on unexpected errors). notifications/quietHours.ts is the single server-side quiet-hour reader; notifications/time.ts is the shared date-key helper. Per-function log lines are routed through log.ts (re-exports firebase-functions/logger for v2 trace/execution context). The B6c split of onGameSessionUpdate + the 4 per-game part-finished triggers is a structural change - see Game session push semantics.

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.

Two reminders exist for the daily question and they are NOT the same:

  • The reminders.ts callable sendDailyQuestionReminder was the original placeholder - that file is gone. The live scheduled nudge is the 2nd-gen Pub/Sub function sendDailyQuestionProactiveReminder in dailyQuestionReminder.ts. It fires at 0 16 * * * America/Chicago (4 PM, 2 hours before today's daily question expires at 6 PM). Queries daily_question docs whose expiresAt is within the next 3 hours via collection group, then for each: (a) skips if the auto-reminder was already claimed today (daily_reminders/auto_{questionDate} doc exists), (b) skips if a manual gentle_reminders/{questionDate} was sent today, (c) skips if anyone has answered. Otherwise it atomically claims the daily_reminders slot, sends FCM to every user in the couple, and writes a notification_queue entry per user.

Shared configuration

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
docs/                    # This manual, QA notes, release prep, store assets

Authentication and pairing flow

Auth providers

Firebase Auth supports two sign-in paths:

  1. Email/password - standard sign-up and login.
  2. Google Sign-In - initiated by the UI layer via Credential Manager (androidx.credentials + GetSignInWithGoogleOptionGoogleIdTokenCredential), which then forwards the extracted idToken to FirebaseAuthDataSource.signInWithGoogle(idToken). That data-source method calls auth.signInWithCredential(GoogleAuthProvider.getCredential(idToken)) (the standard Firebase path) - the idToken is a Google-issued JWT; the data source never holds a Google-Sign-In client id. iOS uses the Google Sign-In SDK on top of the same Firebase Auth APIs through AuthService.swift.

The Android FirebaseAuthDataSource exposes the standard Firebase paths for email/password and Google credential sign-in; iOS uses the same Firebase Auth APIs through AuthService.swift. There is no anonymous sign-in or account-linking flow in the current Android or iOS source. Users sign in directly with email/password or Google.

Pairing flow

The pairing flow is server-mediated because 6-character codes are enumerable. The flow is identical on both platforms.

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 for the per-field immutability matrix.

The couples document model

/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               # 2 strict (all current couples)
  wrappedCoupleKey: string | null
  kdfSalt: string | null
  kdfParams: string | null

encryptionVersion is stamped at 2 (EncryptionVersion.STRICT) on creation; there is no migration state in the current source. encryptionMigrationUsers is not a current field.

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 248-word list (the list is hard-coded as WORDLIST in RecoveryKeyManager.kt; the iOS iphone/Closer/Crypto/Resources/wordlist.txt is a verbatim copy with 248 entries). The phrase has roughly 80 bits of raw entropy (248^10); 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. Both partners hold the same phrase (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other - the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase re-wraps the locally-held keyset and uploads a new wrappedCoupleKey to Firestore - but it desyncs the partner's stored copy; see the landmine Recovery-phrase change desync. The change-phrase path is currently UNWIRED.

iOS does generate and store a recovery phrase in the current build (R24 batch 2+); iOS couples now have the same partner-assisted recovery path as Android. See iOS E2EE gap for the still-pending schemaVersion 3 interop.

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 Conceptual only - not creatable today. The constant is referenced in iOS TODO comments and the Firestore rules helper coupleEncryptionEnabled, but acceptInviteCallable hardcodes encryptionVersion = 2 and throws if E2EE fields are missing. No v0 couple exists in the live system. See iOS E2EE gap.
1 MIGRATING Reserved for a hypothetical in-flight migration. No couple exists at v1 in production today. 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. The only version any couple has ever been created at. Required by both acceptInviteCallable and the Firestore rules (which reject request.resource.data.encryptionVersion != 2).

What the code actually does: the Android EncryptionVersion object defines only STRICT = 2 and NEW_COUPLE_DEFAULT = STRICT. acceptInviteCallable sets const encryptionVersion = 2 unconditionally and throws if any of wrappedCoupleKey/kdfSalt/kdfParams is null. v0 and v1 are conceptual states referenced by iOS TODOs and the rules helper coupleEncryptionEnabled(>= 1); nothing in the production path writes them.

If you need to support a non-v2 couple (e.g. iOS plaintext fallback, legacy migration): the change touches three places at minimum - EncryptionVersion.kt adds the constant, acceptInviteCallable derives it from caller data, and firestore.rules match /couples/{coupleId} allow create block removes the encryptionVersion == 2 gate and adds the corresponding E2EE-field-optional helper. Plan all three together; do not ship one without the others.

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 (46 * 1024 = 47104 KiB; constant ARGON2_MEMORY_KB)
    • iterations: 3 (ARGON2_ITERATIONS)
    • parallelism: 1 (ARGON2_PARALLELISM)
    • 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. The recovery phrase is the only human-readable secret. It is never sent to the server in plaintext.

Sealed-answer partner-proof mode (schemaVersion 3 only)

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), which uses a different gating model.

Flow:

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.

iOS → Android sealed-answer bridge (wrapReleaseKeyCallable)

iOS does not implement Tink's ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM locally, so it cannot produce a keybox:v1: that Android can unwrap. Instead, iOS calls the server-side wrapReleaseKeyCallable (functions/src/releaseKey/wrapReleaseKeyCallable.ts) with its one-time AES-256 answer key and the recipient's Tink public key. The Cloud Function performs the Tink wrap using the Admin SDK and returns a keybox:v1: string that Android's ReleaseKeyEncryptor can decrypt. The function is strict-coupled: caller and recipient must be the two members of an active couple, and the recipient must have published a device public key at users/{recipientUserId}/devices/primary. iOS↔iOS sealed-answer releases continue to use the native Path A envelope and do not require this bridge.

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.

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 one question per couple from the active free pool (the whole pool is tiny, ~75 rows, so the function fetches once and filters in memory) using a server-authoritative, mode-aware, deterministic selection (see Server-authoritative selection). The selected questionId is written into a daily_question/{YYYY-MM-DD} 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. The couple scan is paginated (300 couples/page, ordered by __name__) so deploys and re-runs never load every couple into memory.
  • Document shape:
    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). The callable throws HttpsError('internal', 'No active questions available.') when the pool is empty. If the scheduled run finds the pool empty it logs [assignDailyQuestion] questions pool not seeded -- skipping assignment and writes nothing -- so an unseeded pool silently does nothing (intentional: a transient skip beats a half-couple; the cron retries the next day).

Server-authoritative mode-aware deterministic selection

The picker in functions/src/questions/assignDailyQuestion.ts mirrors the Android client's DailyModeResolver (DOW_DEFAULTS table, frozen in the function source as WEEKDAY_MODE_TAGS): Sunday mode_tiny_date_night (Slow Burn Sunday), Monday mode_soft_monday (Mood Check Monday), Tuesday mode_snack_mission (Tiny Win Tuesday), Wednesday mode_no_phone_moment (Real One Wednesday), Thursday mode_laugh_reset (Laugh It Off Thursday), Friday mode_flirty_friday (Flirty Friday), Saturday mode_weekend_side_quest (Side Quest Saturday). The same date -> the same couple -> the same question, with no randomness and no client/server split. This kills the DQ-MISMATCH class of bugs (one partner sees a different question than the other) and removes cross-timezone partner splits at the root. The frozen DOW -> mode map is unit-tested (assignDailyQuestion.test.ts); if you add a weekday or change a mode tag, update both the client resolver and the server picker in the same change. The picker is also FROZEN against the daily question's daily_fun_mc content: daily_fun_mc rows are filtered out of the HowWell prediction pool and Desire Sync pool (server-side) to match the client-side guards shipped in R30.

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. Couple-key / company-proof (schemaVersion = 2, default for daily answers): enc:v1: ciphertext fields, content is encrypted with the shared couple key. Both partners already hold the couple key after pairing, so reveal decrypts the moment both have answered. Privacy ("not until both answer") is enforced by the Firestore read rule, not a per-answer key handshake. This replaced an earlier fragile sealed-key exchange (commit df32229) that broke when one partner reinstalled.
  2. Sealed / partner-proof (schemaVersion = 3): sealed:v1: payload + sha256: commitment; content key is released only after both partners have submitted, wrapped via ECIES P-256 to the recipient's per-user public key. Used for thread messages and the legacy answer path.

A write must match exactly one of these shapes - the rules reject anything else. Daily answers use schemaVersion 2 by default (the couple-key reveal path was introduced in commit df32229 to replace the fragile sealed-key handshake); thread messages use schemaVersion 3.

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 path differs by schema version:

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.

  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.

Read-path gotcha: FirestoreAnswerDataSource.toLocalAnswer() (the DocumentSnapshot.toLocalAnswer() private helper) hardcodes schemaVersion = 3 and reads encryptedPayload from the doc itself. For the v2 path this is wrong in isolation - v2 daily answers don't have encryptedPayload on the doc; that field lives in the secure/payload subdoc. In practice the v2 reveal still works because computeSealedPhase() in AnswerRevealViewModel reads the user's own answer's schemaVersion from the local SharedPreferences repository (which preserves the actual value), not the partner's. The partner's hardcoded schemaVersion = 3 is dead data on this read path. The actual partner content is fetched via decryptCoupleKeyAnswerFor(), which reads the secure/payload subdoc directly. If you refactor the read path, preserve this invariant: the user's own answer schemaVersion must drive the reveal branch.

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

/users/{uid}
  email: string
  displayName: string
  photoUrl: string | null
  sex: string                   # 'male' | 'female' | '' (set during onboarding)
  partnerId: string | null       # deprecated mirror of the paired user's uid; prefer coupleId lookups
  coupleId: string | null
  plan: string                  # 'free' or premium product id; client-written, not server-authoritative
  createdAt: Timestamp
  lastActiveAt: Timestamp
  fcmToken: string | null       # legacy single-device FCM token; still written by older flows
  platform: 'android' | 'ios' | null
  notifPartnerAnswered: bool
  notifChatMessage: bool
  notifDailyReminder: bool       # NEW (R20) - server-side gate for sendDailyQuestionProactiveReminder
  notifStreakReminder: bool      # NEW (R20) - server-side gate for sendStreakReminder (default true)
  notifPromotional: bool         # NEW (R20) - server-side opt-out for reengagement + challenge-day nudge
  quietHoursEnabled: bool
  quietHoursStartMinutes: int
  quietHoursEndMinutes: int
  timezone: string
  # NOTE: hasPremium is NOT a current root field. Premium state is stored in the
  # server-only /entitlements/premium subdoc and observed by the client from there.
  /entitlements/premium          # written by Cloud Functions only; readable by owner + current partner
    premium: bool
    expiresAt: Timestamp | null
    updatedAt: Timestamp
    productId: string | null     # RevenueCat product identifier (e.g. 'closer_monthly')
    eventType: string            # RevenueCat event type ('INITIAL_PURCHASE', 'RENEWAL', 'CANCELLATION', etc.)
  /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
  # NOTE: iOS stores its couple key in the iOS Keychain (Security.framework), not in this
  # Firestore subcollection. The Keychain item uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  # and kSecAttrSynchronizable=false, so it is device-local and not included in iCloud backup.
  # This matches Android's single-device limitation: a new iOS device for the same user has no
  # couple key until the recovery phrase is entered.
  /outcomes/{dayKey}           # day_0, day_30, day_60, day_90; server-only
    submittedAt: Timestamp
    answers: map
  /notification_queue/{id}     # in-app activity feed (Together screen)
    type: string                # e.g. 'invite_created', 'partner_joined', or whatever the caller writes
    read: bool                  # only field the rules allow the client to update
    createdAt: Timestamp
    # Plus arbitrary caller-supplied fields (the writer spreads them onto the doc).
    # See functions/src/games/onGameSessionUpdate.ts and functions/src/couples/createInviteCallable.ts for examples.

  # NOTE: despite the comment in firestore.rules that says "the app reacts to FCM push, not this collection",
  # the Android `FirestoreActivityDataSource` DOES read this collection for the in-app "Together"
  # activity feed. Client reads + read-flag updates are permitted by the rules; server-side writes
  # are the only way to create records here. The rules comment is stale.
  /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 (always 2), wrappedCoupleKey, kdfSalt, kdfParams
  /daily_question/{YYYY-MM-DD}
    questionId, date, assignedAt, expiresAt
    /answers/{userId}            # schemaVersion 2 metadata, or schemaVersion 3 sealed fields
    /answers/{userId}/secure/{doc}  # schemaVersion 2 encryptedPayload (read-gated)
    /answers/{userId}/releaseKeys/{recipientId}   # schemaVersion 3 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}
  /gentle_reminders/{YYYY-MM-DD}   # one doc per calendar day per couple; daily lock

/invites/{code}                  # server-only writes; 24h TTL
  code, inviterUserId, status: 'pending' | 'accepted' | 'expired'
  createdAt, expiresAt
  wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase

/questions/{questionId}          # SERVER-ONLY (no client rule grants access). Admin SDK reads via pickRandomQuestionId(); Android loads bundled seed JSON via Room (`seed/questions/*.json`).
  text, categoryId, active, isPremium
  # Client (Android) ships `seed/questions/*.json` files as Room assets via `seed/build_db.py`.
  # The Firestore copy exists as the source of truth for the daily-question picker; if it's empty
  # the picker falls back to id 'q_default_daily' (functions/src/questions/assignDailyQuestion.ts).
  # Do not rely on client-side reads of this collection; treat it as write-once admin data.

/couples/{coupleId}/conversations/{conversationId}   # E2E-encrypted chat
  type: 'couple' | 'thread'                           # 'thread' = per-question discussion
  questionId: string | null                           # only on threads
  createdAt: Timestamp
  lastMessageAt: Timestamp | null
  lastMessagePreview: enc:v1: | null                  # E2E-encrypted preview of the latest message
  lastMessageSenderId: uid | null
  reads: map<uid, Timestamp>                          # per-user last-read timestamp
  typing: map<uid, Timestamp>                         # per-user last-typing timestamp (TTL'd client-side)
  /messages/{messageId}
    authorUserId: uid
    text: enc:v1:                                    # for type='text'; isCiphertext() enforced by rules
    type: 'text' | 'image' | 'voice'
    mediaUrl: string | null                          # for image/voice; points at encrypted Storage bytes
    durationMs: number | null                        # for voice messages
    reactions: map<uid, emoji>                       # reactions map (any member may flip their own)
    createdAt: Timestamp
    deleted: bool                                    # tombstone; only author may set this (unsend)

/couples/{coupleId}/date_swipes/{dateIdeaId}        # E2E-encrypted per-date swipe state
  actions: map<uid, { action: enc:v1:, swipedAt: number }>
  # One doc per date idea; both partners' entries live under .actions keyed by uid.
  # Server cannot read action (encrypted); only swipedAt is plaintext (used for ordering/audit).
  # Cross-reference: createDateMatch.ts fires when both partners' actions are 'love'.

/couples/{coupleId}/date_matches/{matchId}
  userIds, dateIdeaId, createdAt
/couples/{coupleId}/date_plans/{planId}
  dateIdeaId, scheduledDate, scheduledTime (encrypted), status: 'draft' | 'planned' | 'completed'
  budget (encrypted), duration (encrypted)
  activity (encrypted), food (encrypted)
  conversationPrompts (encrypted), optionalChallenge (encrypted)
  createdAt, updatedAt
/couples/{coupleId}/date_plan_preferences/{prefId}
  dateIdeaId, preferredDate, preferredTime (encrypted)
  budget (encrypted), duration (encrypted)
  createdAt, updatedAt
/couples/{coupleId}/bucket_list/{itemId}
  title, description, category, addedBy, addedAt
  completedBy, completedAt, isCompleted

# NOTE: this list is not exhaustive. Game-session subcollections (this_or_that, desire_sync,
# how_well, wheel) and challenge-day / memory-lane subcollections are documented in their own
# sections. If you add a new subcollection, update both firestore.rules AND this data model.

/date_ideas/{dateIdeaId}         # read-only catalog
  title, description, category, imageUrl

/rate_limits/{uid}_gentle_reminder   # server-only; rolling-hour transaction counter
  windowStart: Timestamp
  count: int

/entitlement_events/{eventId}    # server-only; idempotency markers
  userId, type, source, processedAt

Cross-references

  • users/{uid}.coupleIdcouples/{coupleId}.
  • couples/{coupleId}.userIdsusers/{uid} (the two members).
  • couples/{coupleId}/daily_question/{date}/answers/{userId}.userIdusers/{uid}.
  • couples/{coupleId}/date_swipes/{dateIdeaId}.actions.{uid}users/{uid} (each swipe entry keyed by uid).
  • entitlement_events/{eventId}.userIdusers/{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.

isSignedIn()                          request.auth != null
isOwner(uid)                          request.auth.uid == uid
isCouplesMember(coupleId)             request.auth.uid in couples/{coupleId}.userIds
otherCoupleMember(coupleId, uid)      the OTHER member of the couple (partner)
partnerReflectedDate(coupleId, dateId, uid)  has the partner already reflected on a given date reflection?
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,}$')
isPublicKey(value)                    matches('^pub:v1:[A-Za-z0-9_-]{40,}$')  # gates users/{uid}/devices/primary write (40+ chars minimum)
isCommitmentHash(value)               matches('^sha256:[A-Za-z0-9_-]{43}$')
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

Per-collection enforcement

users/{uid} - owner can read/create/update their own doc. Root-level premium state is not stored here; the client-writable plan field is informational only, and authoritative premium state lives in the server-only entitlements/premium subdoc. entitlements/, notification_queue/, and outcomes/ are server-only writes; entitlements/ is also readable by the user's current couple partner (couple-shared premium; see Couple-shared premium), and notification_queue/ is readable by the owner for the in-app activity feed (the owner can flip the read flag). fcmTokens/ and devices/ are owner-writable; the devices/ public key is readable by the user's current couple partner only (to wrap release keys). Quiet-hours fields (quietHoursEnabled, quietHoursStartMinutes, quietHoursEndMinutes, timezone) and notification preferences (notifPartnerAnswered, notifChatMessage, notifDailyReminder, notifStreakReminder, notifPromotional) are client-writable so Cloud Functions can honor them server-side.

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. Direct client create is possible only if the document exactly matches the server-created shape (all required fields including encryptionVersion == 2 and valid E2EE key material); in practice acceptInviteCallable creates the doc. update is limited to the rhythm/recovery-wrap field sets via isUpdatingCoupleRhythm() or isUpdatingRecoveryWrap(); everything else is server-only. delete is denied for clients. Field-level immutability helpers (isUpdatingCoupleRhythm, isUpdatingRecoveryWrap) define what each update path may touch. There is no encryption-migration path in the current rules.

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 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 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 (the rule also requires both partners' answers to exist before the release can be written), readable by the named recipient OR the answer owner (the sender needs read for an idempotency existence-check in writeReleaseKey; the keybox is ECIES-encrypted to the recipient, so the sender reading it leaks nothing). 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 (none deployed) The old revenueCatWebhook onRequest handler is in source but not exported from index.ts - see Billing for the enable steps. The unauthenticated health endpoint was removed in an earlier security review.
HTTPS onCall createInviteCallable, acceptInviteCallable, syncEntitlement, submitOutcomeCallable, leaveCoupleCallable, checkDeviceIntegrity, assignDailyQuestionCallable, wrapReleaseKeyCallable, sendGentleReminderCallable, sendThinkingOfYouCallable Caller must be authenticated. Errors throw HttpsError. All v2 → us-central1 via global options.ts.
Firestore onDocumentCreated onAnswerWritten, onMessageWritten, notifyOnDateMatch (under createDateMatch.ts), onDateHistoryCreated, onDateReflectionWritten, onRestoreRequested Create-only; one delivery per new doc.
Firestore onDocumentUpdated onCoupleLeave, onAnswerRevealed, onDateReflectionRevealed, onRestoreFulfilled Update-only; fires when the matched doc changes.
Firestore onDocumentWritten (create+update) onEntitlementChanged, onGameSessionUpdate, onThisOrThatPartFinished, onWheelPartFinished, onHowWellPartFinished, onDesireSyncPartFinished Fires on both create and update; the handler distinguishes via event.data.before/after or by reading the doc state.
Auth onDelete onUserDelete (v1) Auth user deletion cascade. Intentionally v1 (gen 2 has no auth.user().onDelete).
Pub/Sub schedule assignDailyQuestion, scheduledOutcomesReminder, sendDailyQuestionProactiveReminder, sendReengagementReminder, sendStreakReminder, unlockDueMemoryCapsules, sendChallengeDayReminders, aggregateOutcomeStats Cron expression; timezone is America/Chicago only where explicitly set (all the daily-cron senders do).

Per-module responsibilities

  • billing - RevenueCat webhook (source-only; not exported/deployed), entitlement event handlers (onEntitlementChanged), forced re-sync callable (syncEntitlement). Entitlement writes are idempotent (write the same Firestore doc and use entitlement_events/ as a dedup marker).
  • couples - invite create/accept/leave (createInviteCallable, acceptInviteCallable, leaveCoupleCallable, onCoupleLeave), outcome submission (submitOutcomeCallable), scheduled 30/60/90 reminders (scheduledOutcomesReminder), privacy-safe aggregate rollup (aggregateOutcomeStats).
  • dates - mutual-love trigger creates a date match document and notifies the couple (notifyOnDateMatch under createDateMatch.ts); date reflection write/reveal triggers and a history-aggregation trigger.
  • games - game session updates notify the partner and append to notification_queue (onGameSessionUpdate); per-game part-finished triggers fire when one partner finishes an async game (onThisOrThatPartFinished / onWheelPartFinished / onHowWellPartFinished / onDesireSyncPartFinished).
  • notifications - daily question proactive reminder (sendDailyQuestionProactiveReminder), re-engagement (sendReengagementReminder), streak reminder (sendStreakReminder), gentle reminder callable (sendGentleReminderCallable), thinking-of-you callable (sendThinkingOfYouCallable), challenge day reminders (sendChallengeDayReminders), capsule unlock (unlockDueMemoryCapsules). Shared infrastructure: push.ts (canonical FCM send + token reader), quietHours.ts, idempotency.ts, pruneTokens.ts, time.ts, log.ts (B6d).
  • questions - daily question assignment (assignDailyQuestion, assignDailyQuestionCallable), answer write trigger (onAnswerWritten), reveal trigger (onAnswerRevealed), thread message trigger (onMessageWritten).
  • releaseKey - iOS→Android sealed-answer bridge (wrapReleaseKeyCallable); Tink-format keybox:v1: produced server-side.
  • backup - partner-assist restore request flow (onRestoreRequested, onRestoreFulfilled under onRestoreRequested.ts); see the R24-BACKUP landmine.
  • security - Play Integrity verdict verification (checkDeviceIntegrity).
  • users - Auth user deletion cascade (onUserDelete, v1).

Webhook reliability

revenueCatWebhook processes the entitlement write (applyEntitlementEvent) before returning the HTTP 200 acknowledgement. If processing fails, the function returns HTTP 500 so RevenueCat will retry. This changed from an earlier implementation that acked 200 immediately and silently dropped failures. applyEntitlementEvent is idempotent, so retries are safe. entitlement_events/{eventId} remains the idempotency marker.

The webhook handler is now exported from index.ts but is NOT deployed yet — deploy is gated on the signing secret. The export binds defineSecret('REVENUECAT_WEBHOOK_SECRET'), validated codebase-wide at deploy (a missing secret fails every function's deploy). To deploy: in the RevenueCat dashboard create the webhook and enable signature verification, copy the signing secret, firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See functions/src/index.ts for the precise comments. The webhook handler does not currently use a dead-letter queue. Risk: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the 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

assignDailyQuestion                     0 23 * * *    America/Chicago   (11 PM UTC; 6 PM Chicago during CDT, 5 PM during CST -- see Date math DST bug)
scheduledOutcomesReminder               every 24 hours                 (default UTC)
sendDailyQuestionProactiveReminder      0 16 * * *    America/Chicago   (4 PM; 2h before expiry)
sendReengagementReminder                0 12 * * *    America/Chicago   (noon; targets couples 3-10 days quiet)
sendStreakReminder                      0 19 * * *    America/Chicago   (7 PM; couples w/ active streak + no shared action today)
unlockDueMemoryCapsules                 every 1 hours                   (default UTC)
sendChallengeDayReminders               every 24 hours                  (default UTC)
aggregateOutcomeStats                   every 24 hours                  (default UTC)

scheduledOutcomesReminder currently scans all couples with a 200-doc limit and no pagination. It will need to shard or paginate as the user base grows. sendDailyQuestionProactiveReminder is the only deployed scheduled reminder for unanswered daily questions - the placeholder sendDailyQuestionReminder callable in reminders.ts is not in the live reminder path.


Billing

RevenueCat integration

Both clients use the RevenueCat native SDK (purchases:10.12.0 on Android, purchases-ios:5.x via SPM - from: 5.0.0 in Package.swift). 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.

RevenueCat ↔ Firebase Auth identity (commit b99a8338). The Android billing repository calls Purchases.logIn(firebaseAuth.currentUser!!.uid) after every successful Firebase Auth sign-in / sign-up and Purchases.logOut() on sign-out, so a subscription purchased on one device follows the user across reinstalls and never gets orphaned to an anonymous RevenueCat identity. The repository also surfaces PurchasesErrorCode -> user-facing BillingException types so the paywall can show precise errors (PurchaseInvalidError, NetworkError, etc.) instead of a generic "Something went wrong" toast. The QA flow (Sam premium = ON/OFF) was updated to test the identity-persistence path: sign out -> sign back in -> entitlement must still be observed.

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 BillingService (iphone/Closer/Core/Billing/BillingService.swift) does not observe Firestore entitlements. It reads RevenueCat CustomerInfo only, via Purchases.shared.customerInfoStream exposed as BillingService.customerInfoStream. iOS premium state is therefore client-verified, not server-verified - this is a known gap that should be closed before production.

The Android app uses both EntitlementChecker (per-user server-verified premium) and CouplePremiumChecker (couple-shared premium: true if either partner has premium). CouplePremiumChecker is the drop-in used for couple-wide gates like chat media, shared games, and date features so a single subscription covers both partners.

interface EntitlementChecker {
  fun isPremium(): Flow<Boolean>
  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: HMAC-SHA256 signature verification (RevenueCat's webhook signing — there is no Ed25519/public-key option). RevenueCat sends X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>; the handler recomputes HMAC-SHA256("<t>.<rawBody>") with REVENUECAT_WEBHOOK_SECRET (the integration's signing secret), constant-time-compares against v1, and rejects timestamps outside a ±5-min window (replay guard). Missing secret → 500 (config error). Invalid/missing signature or stale timestamp → 401.
  • Body: RevenueCat event payload. Malformed payload → 400.
  • Flow: applyEntitlementEvent(event) runs before the HTTP 200 acknowledgement. If it throws, the function returns HTTP 500 so RevenueCat retries. The function is idempotent, so retries are safe.
  • Idempotency: entitlement_events/{eventId} records processed events. Re-delivered events are dropped.

Not deployed yet -- the export is live in functions/src/index.ts but deploy is gated on seeding REVENUECAT_WEBHOOK_SECRET. See Webhook reliability for the deploy steps.

Premium-gated features and gate pattern

The EntitlementChecker.isPremium() Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a false value rather than throwing or showing empty state.

Gated features (current list, as of 2026-07-08):

  • Premium question packs - QuestionPackLibraryViewModel injects CouplePremiumChecker; the pack filter PackFilter.PREMIUM plus each QuestionCategory.access == "premium" check.
  • Premium wheel categories - CategoryPickerViewModel sets isLocked = category.access == "premium" && !hasPremium; tapping a locked category routes to the paywall.
  • Date Match premium ideas - DateMatchViewModel (R12 fix for A-201); swiping LOVE/MAYBE on a premium idea when neither partner is premium emits paywallRequired and routes to the paywall.
  • Full spin-wheel session history - WheelHistoryViewModel observes premiumChecker.isPremium() and limits the free tier to the most recent sessions.
  • Chat media - ConversationViewModel (R24) gates image/voice upload on couplePremiumChecker (this is the canonical couple-shared gate - "either partner has Premium unlocks chat media for the couple").

NOT currently gated (the v0.2.1 manual claimed these but the source has no gate):

  • "Full answer history (free shows last 7; premium shows all)" - AnswerHistoryViewModel has NO CouplePremiumChecker or EntitlementChecker injection. The 7-answer cap is not implemented.
  • "Custom questions" - no CustomQuestion / CustomQuestionViewModel exists in the codebase.
  • "Private notes" - no PrivateNote / PrivateNoteViewModel exists in the codebase.
  • "Extra categories (beyond the free tier)" - the manual conflates this with the "Premium wheel categories" gate above. There is no separate category count cap.

Future (planned):

  • AI-assisted question suggestions (mentioned in the v0.1.0 plan; no source yet).

If you add a new premium feature, mirror the established pattern: inject CouplePremiumChecker (or EntitlementChecker for the rare per-user gate), collect isPremium() in the VM, navigate to paywallScreen() on false. Server-side: any new premium-only Cloud Function must verify users/{uid}/entitlements/premium before doing work. Do not trust the client flag for server-side gating.

How to gate a new feature:

  1. Inject EntitlementChecker into the relevant ViewModel or Repository.
  2. On entry, collect isPremium(). If false, navigate to the paywall route (paywallScreen() in AppNavigation) with a returnTo argument. Do not silently fail or render empty state.
  3. Server-side: any new premium-only Cloud Function must verify the entitlement via users/{uid}/entitlements/premium before doing work. Do not trust the client flag for server-side gating.

QA testing convention: to test premium features without a real subscription, set the entitlement doc directly:

users/{testUserUid}/entitlements/premium
  premium: true
  expiresAt: <far-future Timestamp>

The EntitlementChecker Flow picks this up reactively. The QA report convention is Sam premium = ON (or = OFF) to track the test setup state per run.


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 implemented (functions/src/notifications/quietHours.ts): onAnswerWritten reads the recipient's quietHoursEnabled / quietHoursStartMinutes / quietHoursEndMinutes / timezone fields and skips the FCM push when the recipient is in their quiet window. Client-side suppression remains as a fail-open backup; the server-side check is what keeps the "no notifications" promise when the app is backgrounded (M-001).

Daily question reminders

sendDailyQuestionProactiveReminder (in functions/src/notifications/dailyQuestionReminder.ts) is the live deployed 2nd-gen Pub/Sub scheduler -- see Schedule for its 4 PM CT cron. It also lives next to a callable sendDailyQuestionReminder in reminders.ts which is now a thin placeholder wrapper (it just writes a notification_queue record and returns; the real push goes through push.ts).

The "reminders.ts" placeholder note in the v0.2.1 manual is now mostly historical -- the live scheduled reminder is sendDailyQuestionProactiveReminder and the callables in reminders.ts are thin wrappers. The real cross-cutting reader is notifications/push.ts and the canonical dedupe is notifications/idempotency.ts -- every notification sender routes FCM through sendPushToUser(uid, content, opts) and any per-event dedupe marker through claimOnce(markRef); this collapses ~10 token readers and ~19 senders into one unit-tested module.

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, thinking-of-you, and challenges

sendGentleReminderCallable is a real, rate-limited function for nudges. It enforces two limits:

  • Per-user: max 5 gentle reminders per rolling hour, gated by a server-side transaction on rate_limits/{uid}_gentle_reminder. The Android-side NotificationRateLimiter is a UX hint, not authoritative.
  • Per-couple: one reminder per couple per calendar day (UTC). The lock is stored in couples/{coupleId}/gentle_reminders/{date} so it survives function restarts and is visible to both partners.

The notification is both an FCM push (for the system tray) and an entry in users/{partnerId}/notification_queue. On rate-limit hit, the function returns { allowed: false, retryAfterMinutes } rather than throwing.

sendThinkingOfYouCallable is the partner-facing "thinking of you" nudge (deployed 2026-07-08 with the functions v2 round). It is auth-gated, rate-limited per-user, and writes the FCM through push.ts; the Android client maps the thinking_of_you payload key to the in-app banner via PartnerNotificationManager (no-op on the live Android side today -- waiting on the iOS-initiated send path).

sendChallengeDayReminders and unlockDueMemoryCapsules (both in gameRetention.ts) handle scheduled nudges. The memory capsule unlock is a Pub/Sub schedule every 1 hours that opens capsules whose lock date has passed. The challenge reminder is scheduled daily.

sendStreakReminder (in notifications/streakReminder.ts) is the 7 PM CT "don't lose your streak" nudge for couples with streakCount > 0 who have not recorded a shared action today. It is deduped per local day via a transactional couples/{id}/streak_reminders/{dateKey} marker, gated on the per-user notifStreakReminder preference and the recipient's quiet-hours window. Known limitation: the "today" boundary uses America/Chicago (the cron's timezone) rather than each couple's local day -- quiet-hours suppression keeps a mistimed fire from landing at a bad local hour.

Note on stale names: gameRetention.ts is the file but its responsibilities are challenge reminders and memory capsule unlocks, not generic "game retention". The file name is a legacy name from an earlier product direction; renaming it is a low-priority cleanup.

Per-user notification_queue

users/{uid}/notification_queue/ is a server-written collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is also read by the Android FirestoreActivityDataSource for the in-app "Together" activity feed. The owner can update the read flag; everything else is server-only.

Game session push semantics (idempotent flag-claim)

The Cloud Function functions/src/games/onGameSessionUpdate.ts is the hub for every partner-facing push derived from a game session. The original implementation diffed status fields to decide whether to send a push, which produced duplicate notifications when both partners updated the doc close together. The current implementation uses an idempotent flag-claim pattern: a separate server-only Timestamp per notification type on the session doc (startNotifiedAt, joinNotifiedAt, partFinishNotifiedAt, finishNotifiedAt) is claimed in a transaction the moment the corresponding push is dispatched. The trigger checks the flag, sends if absent, writes the flag. Re-running the trigger is a no-op.

B6c split the same file into four per-game part-finished triggers plus the main onGameSessionUpdate:

  • onThisOrThatPartFinished (couples/{id}/this_or_that/{sid})
  • onWheelPartFinished (couples/{id}/wheel/{sid})
  • onHowWellPartFinished (couples/{id}/how_well/{sid})
  • onDesireSyncPartFinished (couples/{id}/desire_sync/{sid})

Each fires when its session's answers subcollection has exactly one key (one partner finished, the other hasn't) and idempotently claims partFinishNotifiedAt on the session doc before sending partner_completed_part to the other member. Replacing the old single "if both answers exist, notify finished" pattern. See E-GAME-003 below.

This pattern is the rule for any partner-facing push derived from a game session - never diff, always claim. The flag keys are per-push-type Timestamps: startNotifiedAt (started), joinNotifiedAt (partner joined), partFinishNotifiedAt (one partner finished, the other hasn't), finishNotifiedAt (both completed).

Foreground game-alert banner (R10+)

System-tray notifications are easy to miss when the app is open. R10 added an in-app banner surface (GamePromptController, GamePromptBanner) that mirrors the chat in-app banner pattern: when the app is foreground and a game-start push arrives, a prominent top banner slides in ("<partner> started <Game>" + Join). The banner is suppressed when the user is already on that game's screen - ActiveGameSessionMonitor.enter/leave tracks the active route, and GamePromptController consults it before showing.

Home's "Waiting for you" card was also redesigned as a bold purple-gradient hero that joins the specific game (not the Play-hub fallback). Verified live across all four session games (Spin the Wheel, This or That, How Well Do You Know Me, Desire Sync).

Key Android files: app/src/main/java/app/closer/notifications/GamePromptController.kt, app/src/main/java/app/closer/ui/components/GamePromptBanner.kt, app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt, app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt, app/src/main/java/app/closer/core/notifications/AppMessagingService.kt. Deep-link routes live in app/src/main/java/app/closer/core/navigation/AppNavigation.kt.

PartnerNotificationManager (Android) parses the FCM data payload and routes to the right screen via PartnerNotificationType.routeFor(payload, coupleId). The full routing table (verified against PartnerNotificationManager.kt):

Payload key Notification type Routes to
partner_answered PARTNER_ANSWERED DAILY_QUESTION (the partner's answers/{userId} metadata doc exists; reveal reads it on next visit)
partner_opened_answer PARTNER_OPENED_ANSWER answerReveal(questionId) if questionId in payload, else DAILY_QUESTION
reveal_ready REVEAL_READY answerReveal(questionId) if questionId in payload, else ANSWER_HISTORY
partner_started_game PARTNER_STARTED_GAME gameRouteForType(payload.gameType) - for WHEELSPIN_WHEEL_RANDOM, THIS_OR_THATTHIS_OR_THAT, HOW_WELLHOW_WELL, DESIRE_SYNCDESIRE_SYNC. The game screen auto-joins the couple's active session on open. Falls back to PLAY if gameType is missing. E-003.
partner_completed_part PARTNER_COMPLETED_PART gameRouteForType(payload.gameType) - same routing as started_game
partner_finished_game, game_results_ready GAME_RESULTS_READY gameResultsRouteFor(gameType, sessionId) - for WHEELwheelComplete(sessionId), THIS_OR_THATthisOrThatReplay(sessionId), HOW_WELLhowWellReplay(sessionId), DESIRE_SYNCdesireSyncReplay(sessionId). Falls back to PLAY if either arg is missing. E-003.
challenge_day_ready, challenge_waiting CHALLENGE_WAITING CONNECTION_CHALLENGES
memory_capsule_unlocked CAPSULE_UNLOCKED MEMORY_LANE
daily_question, daily_question_reminder DAILY_QUESTION_REMINDER DAILY_QUESTION
chat_message CHAT_MESSAGE conversation(coupleId, conversationId) if both ids present, else MESSAGES
outcome_reminder OUTCOME_REMINDER SETTINGS (not Outcomes - Outcomes screen itself has no dedicated route yet)
gentle_reminder GENTLE_REMINDER DAILY_QUESTION
partner_joined PARTNER_JOINED pairingSuccess(coupleId) if coupleId known, else HOME
date_match DATE_MATCH DATE_MATCHES
reengagement REENGAGEMENT DAILY_QUESTION
partner_left, partner_deleted_account PARTNER_UNPAIRED HOME (Home surfaces the "Invite partner" CTA for the now-solo user; E-002)

When the app is foreground, the banner takes precedence over deep-link (the user already saw it). When background, MainActivity singleTop launch mode + a server-first read in PartnerNotificationManager ensure the deep-link lands in the active game, not a stale state. E-GAME-001 was a previous bug here (deep-link re-entered a finished session) - fixed by server-first read.

Adding a new notification type: add the enum value in PartnerNotificationType with title/body/channelId/rateType, add a branch in routeFor(...) mapping to the appropriate AppRoute, and add a when branch in fromRemoteType(...) mapping the server's payload key. Server-side: emit the matching payload key from the Cloud Function. All four edits must ship together; mismatches silently drop the notification or route it to a wrong screen.


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

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 (status as of current batch)

The iOS port now implements the strict-E2EE pairing path:

  • createInviteCallable is called with a valid Crockford code and all four required E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) generated from a recovery phrase. The invite is created server-side and can be accepted by an Android partner.
  • acceptInviteCallable returns the four E2EE fields; iOS decrypts encryptedRecoveryPhrase with the invite code (Argon2id + AES-256-GCM, AAD "closer_invite_phrase"), then unwraps wrappedCoupleKey with the recovery phrase (Argon2id + AES-256-GCM, AAD "closer_couple_key"). The unwrapped couple key is stored in the iOS Keychain using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and kSecAttrSynchronizable=false, matching Android's device-bound Keystore behavior.
  • SchemaVersion 2 daily answers interop works end-to-end once both devices share the same couple key. SchemaVersion 3 sealed-answer interop still requires the paired CI run to capture the canonical-JSON and Argon2id vectors documented in iphone/Closer/Crypto/SPEC.md. Until those vectors are filled, the fixture-driven iOS tests skip with a TODO_ANDROID_RUN message.
  • iOS cannot produce a Tink-format keybox:v1: locally for an Android partner. The wrapReleaseKeyCallable Cloud Function closes that gap: iOS sends its one-time AES-256 answer key and the recipient's Tink public key to the function, which returns a Tink-compatible keybox. iOS↔iOS sealed-answer releases use the native Path A CryptoKit envelope.

iOS CryptoKit guidance

The iOS CryptoKit implementation follows these rules of parity with Android:

  • Use CryptoKit's AES.GCM for symmetric encryption. AAD binding must match Android exactly.
  • Use P256.KeyAgreement + HKDF + AES.GCM for the iOS-native Path A ECIES envelope. Tink's ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM is not bit-compatible with raw CryptoKit, so iOS→Android sealed-answer key release goes through the server-side wrapReleaseKeyCallable (see iOS → Android sealed-answer bridge). iOS↔iOS releases use the native Path A envelope.
  • Use SecItemAdd / SecItemCopyMatching for keychain storage. Store the couple key as a generic password item with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and kSecAttrSynchronizable=false so it is device-bound and not included in iCloud backup.
  • For Argon2id, use swift-sodium (libsodium) rather than an unaudited pure-Swift implementation. Verify byte output against the Android BouncyCastle reference before shipping.

Build and release

Android

  • Module: app/
  • Package: app.closer (Java/Kotlin namespace)
  • Application ID: closer.app (the on-device package identifier used by Google Play)
  • 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" in app/build.gradle.kts - but HISTORY.md describes versions up through v0.2.1. The build config has drifted from HISTORY; do not ship a release until they're reconciled. Bump versionName in app/build.gradle.kts when cutting a release.

Biometric recovery phrase reveal

app/src/main/java/app/closer/ui/settings/SecurityScreen.kt gates the recovery phrase behind BiometricPrompt with BIOMETRIC_STRONG or DEVICE_CREDENTIAL. The setting biometricLoginEnabled is persisted via DataStore. On a device without biometric hardware the prompt falls back to device credential (PIN/pattern/password). The phrase is held in SecurityViewModel's _recoveryPhrase MutableStateFlow and cleared on dialog dismiss - never written to logs or analytics.

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 Android config. The repo template does not include a real one; copy from your Firebase project.
  • GoogleService-Info.plist - Firebase iOS config. The repo gitignores this file; copy from your Firebase project into iphone/Closer/GoogleService-Info.plist.
  • 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
  • Glance 1.1.1 (Today widget)
  • Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions)
  • RevenueCat 10.12.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 Firebase, Hilt, Room, domain models, RevenueCat, Kotlin metadata, and BuildConfig. Release builds run minifyEnabled = true and shrinkResources = true. Tink is not explicitly kept in the current rules; add -keep rules if reflection-based Tink paths start breaking in release builds. Always smoke-test a release build before publishing - ProGuard rules for new libraries are easy to forget.

Common commands

./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:compileDebugKotlin      # fast typecheck
./gradlew :app:assembleRelease         # fails without RC_API_KEY

Firebase Functions

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.


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

The authoritative list lives in .gitignore at the repo root. The current entries the agent workflow relies on are:

# Private project docs (agent-only, never commit) - see .gitignore
FUTURE.md          # uppercase; legacy name in gitignore
HISTORY.md
PROJECT.md
STRUCTURE.md
project-requirements.md
DEVELOPMENT_LOG.md
BUILD_SUMMARY.md
SCRIPTS.md
.learnings/
.kotlin/

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. (2026-06-28: the legacy uppercase FUTURE.md - a stale, untracked duplicate backlog - was consolidated into the tracked Future.md and removed. The .gitignore entry is kept so any accidentally-recreated uppercase copy stays ignored, but the one canonical backlog is Future.md.)

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.tsacceptInviteCallable).

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/analytics/CompositeRetentionAnalytics.kt for a current example). The historical reference data/questions/QuestionJsonParser.kt is gone (retired in commit 21392ec2); the same guard pattern is used wherever logging survives the dead-file sweep.
  • 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<T> for fallible operations. ViewModels expose StateFlow<UiState> 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.

Known landmines and recent fixes

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.

What it is: https://closer.app/join/{CODE} (autoVerify App Link) + closer://closer.app/join/{CODE} (scheme fallback). JoinLink.parseCode() (pure, JVM-tested) extracts + validates the code; MainActivity.deepLinkRouteFromIntent stashes it in PendingJoinCodeStore (survives sign-up process death) and returns the accept_invite?code=... route, driven by the existing pendingDeepLink machinery. Re-introduction hazards:

  • Do NOT add a navDeepLink for the join URI on the accept_invite composable. The whole point of routing through MainActivity is that pendingDeepLink waits until the user is past onboarding (currentRoute !in entryRoutes). A NavHost navDeepLink would let the NavController jump to the pairing screen while the user is still signed out / mid-signup - the destination can't load and the tap appears to do nothing.
  • deepLinkRouteFromIntent must keep parsing join links before the if (intent.data != null) return null line, and that line must stay so all OTHER inbound closer:// URIs (our own foreground FCM PendingIntents) still fall through to the NavHost's per-route navDeepLinks. The order is load-bearing.
  • There are TWO stores: PendingInviteStore (inviter's encrypted code + recovery phrase) vs PendingJoinCodeStore (invitee's plain code from a link). Don't merge them - the invitee has no phrase.
  • Share text is built by InviteShareText.build() and must carry the code ONLY (link + raw code), never the recovery phrase shown on the same screen - InviteShareTextTest asserts this.
  • Owner action before App Links verify: host /.well-known/assetlinks.json with the release cert SHA-256, plus a /join/{code} web landing page (Play Store redirect when the app isn't installed). Until then the https link still works via the app chooser; the closer:// scheme always works.

APPCHECK-TOKEN - debug token sourced from local.properties (never hardcode; post-enforcement build requirement)

What it is: the App Check debug token used to be hardcoded in app/build.gradle.kts (committed → anyone with repo history holds a console-registered token, defeating App Check the moment enforcement turns on). It now resolves via the secret() helper in app/build.gradle.kts: local.properties → -P/gradle.properties → env, empty when absent; FirebaseInitializer.seedDebugToken() skips seeding on blank, so tokenless builds (CI) mint their own unregistered token per install. Re-introduction hazards:

  • Never put the token (or any secret) back in a committed file. The historical value e2dc8256-... must be treated as burned - rotate it in Firebase Console before enabling App Check enforcement, or enforcement is hollow.
  • After enforcement is ON: only debug builds from machines whose local.properties carries the (rotated) APP_CHECK_DEBUG_TOKEN can reach Firestore. QA emulator rounds must use locally-built APKs; a CI-built APK (empty token) will be denied - expected, not a bug.
  • local.properties is NOT auto-loaded by Gradle; the explicit Properties() load at the top of app/build.gradle.kts is what makes it work. Don't "simplify" it back to properties[...]/findProperty.

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.
    • The PARTNER reads that uploader-scoped snapshot during restore via the tokenized ?token= download URL stored in the couple-gated manifest (downloadBytes does a plain HTTP GET on the URL - the token is the capability), NOT the uid-scoped Storage read rule (which only lets the owner read their own). The blob is enc:v1: so even a leaked URL yields ciphertext. Re-introduction hazard: if you switch restore to the authenticated SDK download or drop the manifest's tokenized URL, cross-user (partner-assisted) content restore silently breaks once chunks compact into the snapshot. Verified R25: plain GET on the manifest snapshotUrl200 + enc:v1: for a non-owner.
  • 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.
  • Keybox payload carries the phrase too (R24-c). The ECIES plaintext is now an envelope ckx:v1:{json} with {keyset, phrase?} - wrapCoupleKey(..., recoveryPhrase) includes the sender's phrase; unwrapCoupleKey returns TransferredKey(keyset, recoveryPhrase?); storeTransferredKeyset(coupleId, handle, phrase?) persists the phrase so a partner-restored device can reveal it. Backward-compatible: a plaintext WITHOUT the ckx:v1: prefix is a legacy keyset-only keybox → decodes to (keyset, null). Don't remove the legacy branch. The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key - never log it (same rule as the key).
  • Restore requires the couple key first (phrase or partner keybox) - content decrypt is couple-key. Fail soft everywhere (missing key → skip backup / "restore unavailable", never crash; never log keys/plaintext/phrase).
  • Re-request must delete before create (R24-b, Bug A). createRestoreRequest does .set(); over an existing 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_ALERTisEnabled 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 / observeAnswersansweredQuestionIds), 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) - 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). Pack-question loading (now via QuestionPackLibraryViewModel + bundled Room) is local-only and not affected. Note: the older landmine draft referenced QuestionDetailViewModel for pack questions, but that VM was retired by commit 21392ec2 (Tier 1 dead-file sweep) - pack questions are now read directly by QuestionPackLibraryViewModel. 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_DENIEDThisOrThatViewModel: 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.) Fix (R20): abandonSession now does a targeted firestore...document(activeSession.id).update(mapOf("status" to "completed","completedAt" to now)) so affectedKeys == {status, completedAt} ⊆ the allowlist (and the active→completed transition satisfies the monotonic-status clause); finishGame delegates to it. Verified live: Quit → no denial → session active=0 → a different game starts immediately (lockout gone). Re-introduction risk: saveSession() is a CREATE primitive (full doc.set), not an UPDATE primitive. Any code that mutates an existing session must use a targeted update(...) touching ONLY allowlisted keys (status/completedAt/completedByUsers/joinedByUsers) - a full set() silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort Log.d callers, so any new session-completion/abandon path must be exercised live against deployed rules (watch logcat for PERMISSION_DENIED on the session doc), not just compiled. When you add a new server-only session field, it is dropped by saveSession for the same reason - prefer update() everywhere a session already exists.

Recovery-phrase change desync - changing the couple phrase silently breaks the partner's "ask your partner" recovery

What: the recovery phrase is a single per-couple secret that both partners receive at pairing and store locally (Keystore-backed RecoveryPhraseStore / CoupleKeyStore); it is never on the server in plaintext. This is by design and is a feature - it makes the partner a built-in backup: a user on a new device can recover by asking their partner to read them the phrase (Settings → Security), and the Recovery screen now tells them to. The trap: CoupleRepository.changeRecoveryPhraseCoupleEncryptionManager.rewrapWithNewPhrase re-wraps the key under a new phrase and uploads a new wrappedCoupleKey, but only re-saves the new phrase on the changer's device. There is no channel to push the new phrase to the partner's device, so the partner's stored phrase (and their Settings → Security reveal) goes stale - it no longer matches wrappedCoupleKey, silently breaking the primary recovery path. Status (2026-06-29): the change-phrase API is UNWIRED - no UI caller - so the desync is not user-reachable today. It now carries a loud warning at all three layers (CoupleRepository.changeRecoveryPhrase KDoc, the impl comment, rewrapWithNewPhrase KDoc). Re-introduction risk: do not wire a "change recovery phrase" UI without first solving partner re-sharing (force the partner to re-save the new phrase - e.g. re-share + re-confirm on their device - or treat the phrase as a fixed pairing secret that can't be changed). Otherwise a user who changed the phrase and then lost it would be unrecoverable, and "ask your partner" would hand over a wrong phrase. See SECURITY.md (recovery section).

N-001 / N-002 - VMs that wait for the screen to push an id silently no-op if nothing pushes it

Symptom (R15): the Bucket List was entirely non-functional - add/load/complete/delete all did nothing, no error, no logcat. Root cause: BucketListViewModel gated every operation on if (coupleId.isEmpty()) return, expecting the screen to call setCoupleId(...) - but BucketListScreen never did (the nav route passes no coupleId and there's no LaunchedEffect). So coupleId stayed "" and every op returned early silently. Same class hit Date Builder (N-002): savePreference() bailed on dateIdeaId.isEmpty() while nothing ever calls setDateIdeaId, the preference had an empty coupleId, and it wrote to date_plan_preferences - a collection no screen reads. So "Create Plan" silently saved nothing. Fix (R15, N-001): BucketListViewModel resolves the couple itself in init via CoupleRepository.getCoupleForUser(uid)setCoupleIdloadItems (mirrors MemoryLaneViewModel/YourProgressViewModel, the correct pattern). Bucket items encrypt at rest (enc:v1:) once a real coupleId flows. Fix (R15, N-002): DateBuilderViewModel.savePreference() now resolves the couple and creates a real PLANNED DatePlan via repository.savePlan() (writing to date_plans - the collection Home surfaces via getPlansByStatus(PLANNED) within 7 days as "Date coming up"), instead of an unread DatePlanPreference; the dead dateIdeaId guard is gone. Verified live (plan persists status=planned, enc:v1: fields; Home shows the upcoming date). (The model's older "generate a plan from BOTH partners' submitted preferences" vision is still unbuilt - date_plan_preferences has no reader; revisit if that two-sided flow is wanted.) Re-introduction risk: the safe pattern is a VM that resolves its own required context (couple/uid) in init via the injected repository - NOT one that depends on the screen remembering to call a setX(...). Audit: grep -rn "setCoupleId\|setDateIdeaId\|fun set[A-Z]" ui/**/ViewModel and confirm a caller exists, or move the resolution into the VM. A silent if (x.isEmpty()) return guard makes a dead feature look like an empty one - QA must persist real data and confirm it via an admin Firestore read, never trust the empty-state render. (N-002 is a deeper incomplete feature: even fixing the save writes into a collection no screen displays - needs a product decision on what "Plan a Date" does + where the plan is shown.)

M-001 - quiet hours must be enforced SERVER-SIDE (a notification block bypasses client code when backgrounded)

Symptom (R15): "Quiet hours - 10 PM-8 AM, no notifications" did nothing for the case it exists for. With quiet hours ON and the recipient backgrounded/killed, partner chats/answers still posted to the shade. Root cause: quiet hours was local-only (SettingsDataStore, never written to Firestore) and the only check, PartnerNotificationManager.isInQuietHours, runs inside AppMessagingService.onMessageReceived - which FCM invokes only in the foreground. Partner pushes carry a notification block, so when the app is backgrounded/killed the OS renders it directly and no app code runs → the window is never consulted. The Settings copy was therefore a false promise for the primary scenario. Fix (R15): enforce server-side. (1) Client mirrors the window to the recipient's user doc - FirestoreUserDataSource.updateQuietHours() writes quietHoursEnabled + quietHoursStartMinutes + quietHoursEndMinutes + timezone (TimeZone.getDefault().id); NotificationSettingsViewModel syncs on toggle and on init (backfill). (2) functions/src/notifications/quietHours.ts:recipientInQuietHours(userData) computes the recipient's local now via Intl.DateTimeFormat({timeZone}), handles the midnight-crossing window, and is FAIL-OPEN (any missing/malformed field → returns false → deliver). (3) The four partner-action senders skip when it returns true: onMessageWritten, onAnswerWritten, onAnswerRevealed, onGameSessionUpdate. (4) firestore.rules user-doc update allowlist extended for the four new fields. Re-introduction risk: (a) Client-side suppression of a server notification-block push only ever works foreground - any "don't notify when X" rule (quiet hours, snooze, DND) must be enforced where the push is sent (Cloud Functions), or sent as data-only (unreliable when killed). (b) The users/{uid} update rule is a field allowlist (firestore.rules ~L198, hasOnly([...])) - a new client-written user-doc field is silently PERMISSION_DENIED until added there and to FirestoreUserDataSource. (c) Keep the helper fail-open so a bug can only under-suppress (deliver), never wrongly drop a notification. (d) Scheduled/promotional senders (reengagement) already had their own quiet-hours check - the gap was the real-time partner-action path. [Superseded in part by N-NOTIF-001: it turned out the scheduled senders did NOT all honor quiet hours.]

N-NOTIF-001 - a settings toggle is only real if a server reader enforces it (dead Daily/Streak toggles; scheduled-push quiet-hours blind spot)

Symptom (R20): Notification Settings showed 5 controls but only 2 worked. Daily question reminder was local-only (SettingsDataStore) and dailyQuestionReminder sent to everyone with no pref check → dead. Streak reminder was a phantom - local-only AND no streak notification existed anywhere. Quiet hours worked for real-time partner pushes (M-001) but the scheduled/cron senders ignored it (dailyQuestionReminder, gameRetention, scheduledOutcomesReminder), and the window was a hardcoded 10 PM-8 AM the user couldn't change. No promotional opt-out; no OS-permission-off awareness. Fix (R20): (1) mirror ALL prefs to users/{uid} - extended updateNotificationPrefs to also write notifDailyReminder/notifStreakReminder/notifPromotional, and the init backfill re-mirrors on Settings open so pre-existing users heal; (2) gate dailyQuestionReminder on notifDailyReminder !== false + recipientInQuietHours; (3) NEW notifications/streakReminder.ts (couples streakCount>0 with no shared action today, transactional per-day streak_reminders/{dateKey} marker, gated on notifStreakReminder + QH); (4) QH guards added to gameRetention (both senders) + scheduledOutcomesReminder; (5) promotional opt-out enforced on reengagement + the gameRetention challenge-day nudge (notifPromotional); (6) user-settable quiet-hours window via Material3 TimePicker + dynamic description; (7) OS-off banner (NotificationManagerCompat.areNotificationsEnabled() + Settings.ACTION_APP_NOTIFICATION_SETTINGS, re-checked ON_RESUME); (8) firestore.rules user-doc allowlist extended for the 3 new fields. Re-introduction risk: (a) a toggle is only real if a Cloud Function reads the mirrored field - a local-only toggle, or a users/{uid} pref no sender reads, is a DEAD setting; scripts/wiring-scan.sh Tier-4 now fails CI on any unread notif* field. (b) updateNotificationPrefs writes all prefs in ONE set(merge), so adding a field there WITHOUT first adding it to the firestore.rules allowlist makes the whole write PERMISSION_DENIED - silently regressing the previously-working prefs too (logcat NotifSettingsVM: PERMISSION_DENIED; the Log.w in syncNotifPrefs surfaces it). The rules deploy is therefore a hard prerequisite - ship the Tier-1 client and the rules together. (c) scheduled/cron senders are a QH + opt-out blind spot - every new sender must read the relevant pref AND recipientInQuietHours. (d) client QuietHoursManager.isInQuietHours and server recipientInQuietHours must stay in lockstep (inclusive ends, midnight-crossing, and start==end ⇒ "no window"/off) - both are covered by tests (QuietHoursManagerTest, quietHours.test.ts).

C-DARK-UI-001 - game surfaces must use theme tokens, not fixed palette darks

Symptom: This-or-That active gameplay was off-brand and weakly legible in dark mode - option body text and mood/duration chips used fixed CloserPalette.PurpleDeep/PinkAccentDeep (dark values) on a dark surface, and ChoicePromptBackdrop drew a diagonal line + two circles that read like a technical diagram crossing the prompt. Fix (R13): ThisOrThatScreen.kt - A/B options map to MaterialTheme.colorScheme.primary/secondary (light lavender/pink in dark, rich purple/magenta in light) with high-contrast onSurface body text + visible accent BorderStroke + a filled onPrimary/onSecondary selected state; VersusBadge, progress, the N/total+title pills, the mood number-circle, and TotLengthChips all read from colorScheme; ChoicePromptBackdrop redrawn as a soft glow + two faint paired-card silhouettes (low alpha, never crossing the prompt). Light+dark previews added. Re-introduction risk: any in-game surface that hardcodes CloserPalette.* dark values instead of MaterialTheme.colorScheme will go dim/muddy in one theme. Use the theme tokens (or the closerSoft*/isCloserDarkTheme() helpers); verify BOTH themes.

C-ART-EDGE-002 - opaque hero illustrations need feathering; transparent ones don't

Symptom: hero illustrations rendered via direct painterResource(R.drawable.illustration_*) showed a hard bright rounded-rect block on dark (e.g. Today "Weekend Side Quest"). The R11 feather (C-ART-EDGE-001) only covered BrandIllustration/EmptyState. Fix (R13): route opaque (RGB / no-alpha) hero tiles through BrandIllustration(tile = true) (gains featherEdges() + the -night theme variant). Confirmed opacity with identify -format '%[opaque]': illustration_daily_question, couple_paywall, couple_subscription, couple_onboarding, partner_activation, tonight_partner_prompt, couple_invite, together_empty are opaque → feathered. spin_wheel, streak_milestone, reveal_celebration are transparent/celebration → left as direct Image/painterResource (they float; feathering them is wrong). Re-introduction risk: adding a new opaque rounded-rect illustration via raw Image(painterResource(...)) will show a hard edge on dark. Check the PNG's alpha; if opaque, use BrandIllustration(tile=true).

Premium-unlock modal - one-time-gate pattern (driven off CouplePremiumChecker, not the push)

What: ui/components/PremiumUnlockOverlay.kt shows the one-time "Premium unlocked" celebration to BOTH partners when couple-shared Premium activates. It is driven by CouplePremiumChecker.isPremium() (which OR-combines both partners) - NOT by the subscription_entitlement_changed push - so it fires for the purchaser and the partner wherever they are. Hosted at the AppNavigation root next to MessageBubbleOverlay. Gate: persisted premiumUnlockCelebrated flag on SettingsRepository/SettingsDataStore; set on dismiss, auto-reset when Premium lapses (so a re-activation celebrates again). Mirrors lastCelebratedStreakMilestone. Re-introduction risk: gating the modal on the push route alone would miss the purchaser (and the partner if FCM is flaky on emulators). Keep it observing CouplePremiumChecker. Don't forget to reset the flag on lapse, or a second subscription period never re-celebrates.

F-RACE-001 - duplicate game-start push on rapid partner update

Symptom: both partners got a "game started" push when only one started it. Caused by diffing status field on game session update trigger; two near-simultaneous updates both saw the diff. Fix: replaced status-diff with idempotent flag-claim on a notificationsSent map (commit 6e79cd9). See Game session push semantics. Re-introduction risk: any new game event that wants to push MUST use the flag-claim pattern, not status diff.

Symptom: tapping a game-start notification re-entered a finished play screen instead of the active one. Fix: MainActivity singleTop launch mode + server-first read in PartnerNotificationManager (commit b9b1560). Re-introduction risk: changing MainActivity launch mode, or making the navigation read from local state before fetching server state.

E-GAME-002 - game-start push easy to miss when app is foreground

Symptom: system-tray notification was buried; partner missed game starts. Fix: foreground in-app banner via GamePromptController + bold Home "Game waiting" hero (commit 38fdc6d). See Foreground game-alert banner. Re-introduction risk: removing GamePromptController from the FCM message handler path, or breaking ActiveGameSessionMonitor.enter/leave in any game's ViewModel.

C-NAV-001 - back from Home resurfaces onboarding/auth

Symptom: after login + Home + BACK, app returned to onboarding instead of exiting. Fix: login flow must popUpTo the auth destination after successful navigation (commit ebd3b2e). Re-introduction risk: adding a new auth flow without popUpTo. The pattern is in AppNavigation.kt.

C-SEC-001 - recovery phrase reading wrong store on accepter

Symptom: after accepting an invite, the recovery-phrase reveal in Settings showed a disabled state with wrong "invite your partner" copy. The accepter's phrase was being read from the inviter's path. Fix: SecurityViewModel now reads via encryptionManager.recoveryPhrase(coupleId) from CoupleKeyStore (R10 fix phase, commit 9c84c36). Re-introduction risk: any future change to CoupleKeyStore access that bypasses EncryptionManager. Always go through EncryptionManager.

Back-stack gotchas (C-NAV-002, C-NAV-003)

Symptom: Wheel results → BACK re-entered finished play screen (C-NAV-002); Wheel History / Past Games / Partner Home showed double app bars because the route was in both shellBackRoutes and the screen's own TopAppBar (C-NAV-003). Fix: popUpTo(WHEEL_SESSION){inclusive=true} on session→complete navigation; removed WHEEL_HISTORY/GAME_HISTORY/PARTNER_HOME from shellBackRoutes in AppNavigation.kt. Re-introduction risk: adding new screens with their own TopAppBar to shellBackRoutes - check the route list before adding.

Home duplicate pending-action card (C-HOME-001)

Symptom: Home showed the same pending action twice - once in the primaryAction hero, once in the buildPendingActions row. Fix: buildPendingActions().filterNot { it.target == primary?.target } to dedupe (R10). Re-introduction risk: adding a new pending-action type without checking it isn't already promoted to hero.

Splash-exit crash - "ALL notifications open and immediately close" (cold-start)

Symptom: tapping ANY notification (chat, every game push) cold-started the app and it opened-and-closed instantly - looked like "all notifications broke at once". Normal launcher cold-start and adb am start were both fine, which masked it. Root cause: MainActivity.onCreate splash-exit listener (added in the branding "loading state" commit 95cad84) called provider.iconView.animate(). On a notification / PendingIntent cold-start the OS hands the splash over without an icon (SplashScreenView: Icon: view: null), so provider.iconView throws an internal NPE (SplashScreenViewProvider$ViewImpl31.getIconView) → onCreate crashes → "Force finishing activity". am start uses a different splash transfer, so it never hit the null-icon handover. Fix: wrap the icon scale in runCatching and the view fade in runCatching{...}.onFailure{ provider.remove() } so the splash is ALWAYS removed and onCreate never crashes (MainActivity.kt). Re-introduction risk: ANY touch to MainActivity splash/onCreate, launch mode, theme, manifest, or a "loading/branding" commit can re-break the shared cold-start path. Re-run qa/entrypoint_smoke.sh (real push → am kill'd app → tap the shade) after such changes - am start is NOT a valid test for this class. "Opens-and-closes / flashes" ⇒ assume a crash and pull the FATAL stack first; don't theorize routing. Many features broken at once ⇒ suspect the SHARED entry path, not each handler.

Symptom: notifications "broke again" intermittently (race / duplicate navigation). Root cause: routing was built on BOTH an ACTION_VIEW + closer:// data Uri (auto-handled by NavController for routes with a navDeepLink) AND an app_route extra (pendingDeepLink) → two mechanisms for one tap. Fix/invariant: app-posted notifications carry the resolved route in the app_route extra ONLY; routing is MainActivity.deepLinkRouteFromIntentpendingDeepLinkAppNavigation.navigateRoute. Never also set a data Uri. The pendingDeepLink consumer must fire on any main screen (currentRoute !in entryRoutes), not only HOME. See Notification deep-link routing.

E-GAME-003 - async first-finisher left the waiting partner un-notified

Symptom: one partner finished an async game (this_or_that/wheel/how_well/desire_sync) and the OTHER (idle/away) got nothing - the session only flips to completed (→ partner_finished_game) when BOTH answer, so onGameSessionUpdate (watches the session doc) never fired on a single finish. Fix: new Cloud Function onGamePartFinished on couples/{coupleId}/{gameType}/{sessionId} - when answers has exactly 1 key, idempotently claims partFinishNotifiedAt on the session doc and sends partner_completed_part ("X finished their part - your turn to play!") to the other member (deployed; functions/src/games/onGameSessionUpdate.ts). See Game session push semantics. Re-introduction risk: changing the async-answer doc path or the both-answered→completed transition; QA must always test the asymmetric "one finishes, the other never played" state, not just both-sides-through.

BANNER-LIFE-001 - game-start banner must be consumed when the user enters the session, otherwise the next game's banner never shows

Symptom (R30): a banner for "Sam started This or That" lingered across both partners joining the session; once the game ended, the next game-start push correctly created a new banner but it looked like the old one was still on screen because GamePromptController keyed its active-banner set on sessionId alone and never consumed on entry. Fix (R30, C1): GamePromptController.consumeForSession(sessionId) is called from every game's enter/leave (the same ActiveGameSessionMonitor hook the foreground-suppression check uses), and the controller replaces stale banners on a new start (last-wins by timestamp) so a banner for a session the user is already inside is never shown. Re-introduction risk: any new entry point into a game (a deep link, a partner-joined push, a "rematch?" CTA) that bypasses ActiveGameSessionMonitor.enter and therefore doesn't call consumeForSession will leave the previous banner alive - and the next push will look like a duplicate. The B6c split of onGameSessionUpdate into per-game part-finished triggers means a new game also needs a per-game monitor hook, not a shared one. Verified live across all four session games post-R30.

FUNCTIONS-V2-DEPLOY - 2nd-gen deploys can fail mid-deploy with "Changing from HTTPS function to background triggered function" if a stray service is left behind

Symptom (2026-07-08): a deploy that swapped a Cloud Function from HTTPS to Firestore-triggered (e.g. promoting a callable to a scheduled job, or splitting a function module) failed with "Changing from an HTTPS function to a background triggered function is not allowed". A first-ever v2 deploy can also fail with Eventarc service-agent IAM propagation races - the "retry in a few minutes" failures. Root cause: an orphaned https service (from a partial deploy or a previous attempt) is blocking the new background trigger from being created under the same name. The 2nd-gen quota was the proximate cause of the initial deploy failure (Cloud Run CPU quota couldn't fit ~35 services at v2's default 1 vCPU/instance; container healthchecks failed with "Quota exceeded for total allowable CPU per project per region") - functions/src/options.ts pins cpu: 'gcf_gen1', concurrency: 1, maxInstances: 5 as the workaround. Fix / deploy pattern: when the "Changing from an HTTPS function to a background triggered function" error appears, firebase functions:delete <stray-name> then redeploy. Diagnose with firebase functions:list - healthy Firestore triggers show google.cloud.firestore.document.v1.* in the trigger column; strays show https. For the Eventarc propagation race, wait 2-3 minutes and retry. At launch: request the Cloud Run CPU quota increase (console → IAM & Admin → Quotas, us-central1), then drop cpu/concurrency from options.ts to restore 1 vCPU + concurrency 80. Re-introduction risk: a deploy that changes a function's type (callable → scheduled → trigger) under the same name WILL fail. Plan the rename + 2-step delete/redeploy if you need to refactor a function's surface.

A-201 - Date Match premium ideas ungated (a badge is NOT a gate)

Symptom: free users could view, like and match isPremium date ideas with no paywall. DateMatchRepositoryImpl.getDateIdeas() returned DateIdeaSeed.all with no entitlement filter; DateMatchViewModel had no CouplePremiumChecker; DateMatchScreen rendered only a cosmetic PremiumBadge(). (Server still blocked real premium self-grant, so only premium content leaked, not the entitlement.) Fix (R12): DateMatchViewModel injects CouplePremiumChecker; swipeCurrent intercepts LOVE/MAYBE on a premium idea when neither partner is premium → emits paywallRequiredDateMatchScreen navigates to the Paywall; SKIP still passes; the deck stays on the card. Mirrors the established gate pattern. Re-introduction risk / lesson: a feature can ship an isPremium content flag + a PremiumBadge with no enforcement at all. When adding premium content, wire a real CouplePremiumChecker gate (filter OR paywall-on-interaction) - a badge is a label, not a lock. Audit by trying to USE premium content as a free user, not by grepping for checker usages (which only finds the features that already have one).

Theme-variant + soft-edge art (C-DARKART-001, C-ART-EDGE-001, C-ART-EDGE-002 closed; theme scanner now mandatory)

Symptom: (1) art didn't follow the IN-APP theme - CloserTheme(darkTheme) only swaps Compose colors, while painterResource/-night drawables resolve off the system uiMode, so app-Dark on a light-mode phone showed light illustrations on a dark screen (C-DARKART-001). (2) Tiled illustrations showed a hard rounded-rect edge instead of blending (C-ART-EDGE-001). (3) Hero illustrations rendered as opaque tiles had a hard bright block on dark (C-ART-EDGE-002). Fix (R11): CloserTheme provides LocalAppInDarkTheme; BrandIllustration loads each drawable through context.createConfigurationContext(cfg) with UI_MODE_NIGHT_* from LocalAppInDarkTheme (theme-correct -night), and feathers its 4 edges to transparent via graphicsLayer{compositingStrategy=Offscreen} + drawWithContent BlendMode.DstIn gradients; EmptyState routes its image through BrandIllustration. Fix (R13): opaque hero tiles routed through BrandIllustration(tile=true) with -night variants; transparent celebration art (spin_wheel, streak_milestone, reveal_celebration) left as direct Image/painterResource because feathering floats is wrong. Fix (R15): scripts/theme-scan.sh is a mandatory Pass C pre-check that statically detects hardcoded colors, direct painterResource/Image usage outside BrandIllustration, mismatched container/surface colors, and missing previews. Run it before any manual visual sweep; file findings in ClaudeReport.md and counts in ClaudeQACoverage.md. Re-introduction risk / still-open: any new art rendered via a direct painterResource(R.drawable.illustration_*) (NOT BrandIllustration) bypasses theme-correct loading and feathering. Always route opaque rounded-rect illustrations through BrandIllustration; keep MaterialTheme.colorScheme for surfaces; add Tier 1/2 previews for new screens. A hardcoded CloserPalette.* dark value or a raw Color.White/Color.Black will be flagged by theme-scan.sh.


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. Admin SDK (Cloud Functions using firebase-admin) bypasses rules entirely. Anything that must be server-only is denied at the client rules level for defense in depth, but the real enforcement is "the client never gets the Admin SDK credentials."
  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 field presence check, recovery phrase wipe, and unconditional encryptionVersion = 2 assignment all in one file. Pairs cleanly with the iOS E2EE code shipped in R24 - see iOS E2EE gap for the still-pending schemaVersion 3 path.
  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. app/src/main/java/app/closer/notifications/GamePromptController.kt - foreground game-alert banner; PartnerNotificationManager.kt - deep-link routing.
  12. app/src/main/java/app/closer/core/navigation/AppNavigation.kt - all routes, the back-stack invariants (C-NAV-001/002/003 fixes), and the shellBackRoutes list.
  13. iphone/Closer/Services/FirestoreService.swift - the iOS side of cross-platform data contracts.
  14. 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. See also Known landmines and recent fixes before changing anything in the listed areas.