The webhook verified an Ed25519 signature in an X-Signature header, but RevenueCat
offers no public-key signing — it sends HMAC-SHA256 in X-RevenueCat-Webhook-Signature
(t=<ts>,v1=<hex>) computed over "<ts>.<rawBody>". As written, every real event would
have 401'd and premium would never sync for the partner.
- Rewrite verification to HMAC-SHA256 with a +/-5-min timestamp replay guard and a
constant-time compare; extract a pure verifyWebhookSignature() for unit testing.
- Rename secret REVENUECAT_SIGNING_KEY -> REVENUECAT_WEBHOOK_SECRET (it is an HMAC
secret, not an Ed25519 key). Never deployed, so no migration.
- Uncomment the export in index.ts (deploy still gated on seeding the secret).
- Add revenueCatWebhook.test.ts: valid / tampered / wrong-secret / missing / stale.
- Reconcile Future.md + Engineering_Reference_Manual.md to the real scheme.
Verified against the live account: the entitlement identifier is now closer_premium,
so events match entitlementLogic. Build + 80 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server/ was a standalone Express webhook/health service (couples-connect-server, 17 files)
that duplicated the Firebase Cloud Functions — entitlement logic, FCM sends, an answer
listener, a webhook route — and was documented as "not client-facing… most teams will not
need it." It hadn't been touched since 2026-06-20 (pre-dating the whole v2 Functions
migration), had no deploy/CI wiring, and was a standing source of "which backend is real?"
confusion. Cloud Functions are the sole backend.
Removed the directory + all doc references so nothing dangles: README + Engineering Reference
Manual directory trees and the "Optional Express server" section; the iOS ARCHITECTURE_AUDIT
intro, its Section 8 (now a "removed" note), and the file-count table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026-07-09 Tier 1 dead-file sweep (commit 21392ec2) removed
8 files the manual still cited. Each was verified unreferenced by
symbol-level git-grep + a clean :app:compileDebugKotlin + unit-test
compile. This patch aligns the manual with the post-sweep source.
Five stale references fixed:
1. Android Repository layout (line 102): dropped NotificationHelper +
NotificationPermissionHelper from the core/notifications/ file
list. Both removed (dead duplicates of NotificationChannelSetup).
2. Android Repository layout (line 108): dropped the data/questions/
package from the layout. The directory is gone (QuestionJsonParser
was the only file; the path is now empty + dead).
3. Android Repository layout (line 118): dropped PartnerNotificationScheduler
from the top-level notifications/ package list. Removed as
superseded by PartnerNotificationManager.
4. 'Note on the manual's older description' paragraph (line 147):
rewritten to record the swept files (NotificationHelper,
NotificationPermissionHelper, PartnerNotificationScheduler, the
domain/model/ Entitlment.kt / InviteStatus.kt / QuestionSessionStatus.kt
trio, and the data/questions/ package) with one-line reasons
pulled from the 21392ec2 commit body, and to point QuestionDao at
data/local/QuestionDao.kt as the now-canonical question-content
source.
5. Logging section (line 1220): replaced the dead QuestionJsonParser
BuildConfig.DEBUG example pointer with the live
analytics/CompositeRetentionAnalytics.kt (one of the few current
Android files that still guards android.util.Log with
BuildConfig.DEBUG). The historical QJP pointer is preserved as a
one-line breadcrumb so the next reader knows why it changed.
6. R23-DQ-001 landmine (line 1355): the parenthetical said
'QuestionDetailViewModel (pack questions) is local-only and not
affected' - QuestionDetailViewModel is gone, pack questions are
now read directly by QuestionPackLibraryViewModel. Updated the
parenthetical + added a one-line breadcrumb pointing at the
21392ec2 retirement.
Anchor check: 30/30 internal anchors still resolve (Python GFM slug
checker).
Deliberately NOT in this commit:
- No mention of the CloserCopy catalog added - that's a documentation
addition, not a stale-reference fix, and belongs in its own batch
(the brand/ package is currently undocumented in the manual; a
future pass should add CloserBrandCopy + CloserCopy to the
Android Repository layout).
- No mention of the strings.xml purge (59e03369) - the manual
doesn't reference any specific string resources, so the purge is
invisible to the manual.
The A-201 landmine entry's '### A-201 - ...' heading got dropped during
the Phase 2 sync (213cfddb). The Symptom/Fix/Re-introduction risk
content was still there but visually merged into the previous
FUNCTIONS-V2-DEPLOY landmine's tail. Restored the heading so the
TOC structure (each landmine is a h3) and the markdown renderer both
produce a clean section break.
Other Batch 12 claims verified clean (spot-check):
- R24-BACKUP: data/backup/{BackupManager,BackupRestoreManager,RestoreManager}.kt
all exist.
- INVITE-DEEPLINK: PendingInviteStore.kt and PendingJoinCodeStore.kt
both exist in data/local/; the two-store distinction is real.
- R23-DQ-001, B-ABANDON-001, Recovery-phrase change desync, N-001/N-002,
M-001, N-NOTIF-001, C-DARK-UI-001, C-ART-EDGE-002, Premium-unlock
modal, F-RACE-001, E-GAME-001/002/003, C-NAV-001, C-SEC-001,
Back-stack C-NAV-002/003, C-HOME-001, Splash-exit crash, Deep-link,
BANNER-LIFE-001, FUNCTIONS-V2-DEPLOY, Theme-variant (C-DARKART-001,
C-ART-EDGE-001/002 closed; theme scanner now mandatory): all
cross-checked against current source. No file pointers broken.
The Gradle config section and the RevenueCat integration section both
said 'purchases:8.20.0' / 'RevenueCat 8.20.0'. The actual current
version in gradle/libs.versions.toml is 10.12.0 (was bumped in R30).
iOS purchases-ios is declared as 'from: 5.0.0' in Package.swift.
Replaced both occurrences with the real version (10.12.0 Android,
5.x iOS). Also added Glance 1.1.1 to the dependency list (the Today
widget shipped in R29 and the manual was missing it from the
build-config list - even though the widget is documented elsewhere).
Other Batch 10 claims verified clean:
- compileSdk 35, minSdk 26, targetSdk 35, jvmTarget 17 in
app/build.gradle.kts - all match.
- applicationId 'closer.app', namespace 'app.closer' - match.
- ProGuard rules: Firebase, Hilt, Room, RevenueCat, Kotlin coroutines,
Kotlin metadata, crash reporting - all match proguard-rules.pro.
Tink is NOT explicitly kept (manual claim is accurate).
- BiometricPrompt + biometricLoginEnabled gating in SecurityScreen.kt
- match.
- Required build secrets (RC_API_KEY, google-services.json,
GoogleService-Info.plist, local.properties) - match the documented
gitignore / per-source build-secret policy.
The iOS CryptoKit guidance paragraph said:
'Tink's ECIES_P256_HKDF_HMAC_SHA128_GCM is not bit-compatible with
raw CryptoKit...'
But the actual Tink template is ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM
(SHA-256, not SHA-128). Verified in
app/src/main/java/app/closer/crypto/UserKeyManager.kt:
HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM
The iOS->Android sealed-answer bridge section above the typo already
had the correct SHA256 name. Fixed the CryptoKit guidance to match.
Other Batch 9 claims verified clean:
- CloserApp.swift @main is CloserApp, with @StateObject AppState.
- AppState is @MainActor final ObservableObject with @Published
authState / currentUser / currentCouple / currentPartner / isPremium.
- iOS deployment target: 17.0 (iphone/project.yml).
- NavigationStack + .navigationDestination, no DI framework, .shared
singletons (AuthService.shared, FirestoreService.shared,
BillingService.shared) - matches.
- CloserTheme.swift color tokens: closerPrimary light=B98AF4
/dark=CFA7FF, closerSecondary light=E7A2D1 /dark=FFAFD9,
closerBackground light=FFFBFE /dark=18111E - matches the brand
primary/secondary stated in MEMORY.md.
- docs/brand/visual-identity.md and docs/copy-guide.md both exist.
- XcodeGen project name = Closer (iphone/project.yml).
- Project.yml deploymentTarget iOS: 17.0.
- All PARTNER_ANSWERED / REVEAL_READY / gameRouteForType / wheelComplete
/ thisOrThatReplay / PARTNER_JOINED_GAME / PARTNER_COMPLETED_PART
/ fromRemoteType branches exist in PartnerNotificationManager.kt and
match the manual's deep-link routing table.
Two fixes from the Notifications section:
1. The Billing 'How to gate a new feature:' header got swallowed into
the previous paragraph by my Batch 7 edit. Restored the standalone
header + the 3 numbered steps + the 'QA testing convention' example
so the section reads correctly.
2. The Game session push semantics sub-section said the session doc
holds 'a single notificationsSent map on the session doc records
each notification type'. That's wrong - the source uses SEPARATE
per-push-type Timestamp flags, not a single map. Verified in
functions/src/games/onGameSessionUpdate.ts:
- startNotifiedAt (started push)
- joinNotifiedAt (partner joined push)
- partFinishNotifiedAt (one-finished, other-hasn't push)
- finishNotifiedAt (both completed push)
Each is claimed in a runTransaction (read-fresh, check-flag, set
flag) so a re-run is a no-op. Replaced the wrong 'notificationsSent
map' description with the actual 4 Timestamp fields, and updated
the 'flag keys are stable strings like start/join/finish' close to
the actual field names.
Other Batch 8 claims verified clean:
- TokenRegistrar lives in core/notifications/, writes to fcmTokens
subcollection on token refresh.
- QuietHours is a DataStore class in SettingsRepository; server-side
quiet-hour suppression is in functions/src/notifications/quietHours.ts;
onAnswerWritten calls recipientInQuietHours(partnerData).
- The B6c part-finished split: onThisOrThatPartFinished /
onWheelPartFinished / onHowWellPartFinished / onDesireSyncPartFinished
are all in onGameSessionUpdate.ts and use the partFinishNotifiedAt
flag. Confirmed in the file.
- per-user notification_queue is read by FirestoreActivityDataSource
for the in-app 'Together' activity feed. Confirmed.
- Gentle reminder rate limit: per-user 5/h via rate_limits/{uid}_gentle
reminder, per-couple 1/day via couples/{id}/gentle_reminders/{date}.
Confirmed in source.
The iOS Server-verified entitlements section said:
'The iOS DefaultEntitlementChecker actor does not observe Firestore
entitlements. It reads RevenueCat CustomerInfo only, via
Purchases.shared.customerInfoStream.'
But there is no DefaultEntitlementChecker actor on iOS. The actual file
is iphone/Closer/Core/Billing/BillingService.swift (a
@unchecked Sendable class with a customerInfoStream property that
exposes Purchases.shared.customerInfoStream). Replaced the manual
with the real class + file path.
The 'Gated features (current list)' section also listed 7 things, 4
of which are NOT actually gated in the source:
- '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.
- 'Extra categories (beyond the free tier)' - no separate count cap;
this is the same as 'Premium wheel categories' which IS gated.
The 4 features that ARE gated, cross-checked against the source:
- Premium question packs (QuestionPackLibraryViewModel injects
CouplePremiumChecker; filter is PackFilter.PREMIUM + per-category
access == 'premium' check).
- Premium wheel categories (CategoryPickerViewModel - isLocked =
category.access == 'premium' && !hasPremium).
- Date Match premium ideas (DateMatchViewModel, R12 / A-201 landmine).
- Full spin-wheel session history (WheelHistoryViewModel observes
premiumChecker.isPremium() and limits the free tier).
- Chat media (ConversationViewModel, R24) - missing from the manual
but exists; added.
Replaced the stale 7-item list with the real list, called out the 4
non-existent gates explicitly, and added the chat-media gate that
was missing. Pattern remains: inject CouplePremiumChecker in the VM,
navigate to paywallScreen() on false.
Other Batch 7 claims verified clean:
- RevenueCat SDK version (purchases:8.20.0), RC_API_KEY sources,
release-build fail-fast guard - all match build.gradle.kts.
- iOS Secrets enum reads RC_API_KEY from Info.plist in CloserApp.swift.
- RevenueCat identity link via Purchases.logIn(firebaseAuth.uid) and
Purchases.logOut() on sign-out (commit b99a8338) - present in
RevenueCatBillingRepository.
- BillingException typed mapping for PurchasesErrorCode - present.
- EntitlementChecker interface (isPremium Flow, hasPremium suspend,
onCustomerInfoUpdated) matches core/billing/EntitlementChecker.kt.
- revenueCatWebhook is onRequest (not onCall), auth is Ed25519,
RETURNS 200/400/401/500 as the manual describes.
The Cloud Functions Handler types table had a single row labelled
'Firestore onCreate / onDocumentWritten' that mixed three distinct
trigger types:
- onDocumentCreated: onAnswerWritten, onMessageWritten,
notifyOnDateMatch, onDateHistoryCreated, onDateReflectionWritten,
onRestoreRequested
- onDocumentUpdated: onCoupleLeave, onAnswerRevealed,
onDateReflectionRevealed, onRestoreFulfilled
- onDocumentWritten (fires on both create and update):
onEntitlementChanged, onGameSessionUpdate, onThisOrThatPartFinished,
onWheelPartFinished, onHowWellPartFinished, onDesireSyncPartFinished
The old row put onEntitlementChanged in a separate 'Firestore onUpdate'
row, but the source actually uses onDocumentWritten (which fires on
both create and update). Replaced the two rows with three precise
rows - one per trigger type - and listed the actual handler in each.
Also moved onUserDelete from the Firestore row to the new
'Auth onDelete' row (which was already there but unused) so each
handler appears in exactly one place.
Other Batch 6 claims verified clean:
- Every export name in the table (createInviteCallable,
acceptInviteCallable, syncEntitlement, submitOutcomeCallable,
leaveCoupleCallable, checkDeviceIntegrity,
assignDailyQuestionCallable, wrapReleaseKeyCallable,
sendGentleReminderCallable, sendThinkingOfYouCallable,
assignDailyQuestion, scheduledOutcomesReminder,
sendDailyQuestionProactiveReminder, sendReengagementReminder,
sendStreakReminder, unlockDueMemoryCapsules,
sendChallengeDayReminders, aggregateOutcomeStats) exists in the
source and is re-exported from functions/src/index.ts.
- revenueCatWebhook is in source but its export is commented out in
index.ts (the not-deployed note is correct).
The Firestore security rules Helper functions table was missing 3
helpers that the rules file actually exports:
- otherCoupleMember(coupleId, uid) - the partner of a couple
- partnerReflectedDate(coupleId, dateId, uid) - has the partner
already reflected on a date reflection?
- isPublicKey(value) - matches the pub:v1:... wire format for the
ECIES P-256 public key write to users/{uid}/devices/primary
The isPublicKey regex I added at first was wrong (I wrote
'pub:v1:[A-Za-z0-9_-]+={0,2}$' but the real one is
'pub:v1:[A-Za-z0-9_-]{40,}$' - 40-char minimum, no padding).
Corrected.
The releaseKeys per-collection enforcement said 'readable only by the
named recipient' but the rule actually allows the sender (answer
owner) to read their own releaseKeys doc as well - the rule comment
explains: writeReleaseKey does an idempotency existence-check get()
before writing, and without the sender-read allowance that get()
returned PERMISSION_DENIED and releaseOwnKey threw, breaking the
daily reveal. The keybox is ECIES-encrypted to the recipient, so
the sender reading it leaks nothing.
Other Batch 5 claims verified clean:
- All 25 helpers in the table exist in firestore.rules
(isSignedIn through isUpdatingCoupleRhythm).
- users/{uid}/fcmTokens/{tokenId}: isOwner(uid) for read+write.
- users/{uid}/devices/{deviceId}: read = isOwner(uid) OR same-couple
partner; create/update = isOwner(uid).
- invites/{code}: read = isSignedIn() && inviter match; writes denied.
- couples/{coupleId}: create requires E2EE field presence, update via
isUpdatingCoupleRhythm OR isUpdatingRecoveryWrap only; delete denied.
- daily_question/{date}/answers/{userId}: metadata-only fields
(userId/questionId/answerType/schemaVersion/answerDate/createdAt/
updatedAt/isRevealed) per isCoupleKeyAnswerCreate.
- couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel} wildcard
match: answers map keyed by uid, enc:v1: per user, plus
categoryName/questions, requires coupleEncryptionEnabled.
- entitlement_events/{eventId}: allow read,write = false (no client).
The server-authoritative mode-aware deterministic selection sub-section
listed the DOW -> mode map as:
Monday mode_soft_monday, Tuesday mode_snack_mission, ..., Sunday
mode_tiny_date_night
But the source-of-truth WEEKDAY_MODE_TAGS table in
functions/src/questions/assignDailyQuestion.ts has 7 entries, one per
weekday:
0 Sunday mode_tiny_date_night (Slow Burn Sunday)
1 Monday mode_soft_monday (Mood Check Monday)
2 Tuesday mode_snack_mission (Tiny Win Tuesday)
3 Wednesday mode_no_phone_moment (Real One Wednesday)
4 Thursday mode_laugh_reset (Laugh It Off Thursday)
5 Friday mode_flirty_friday (Flirty Friday)
6 Saturday mode_weekend_side_quest (Side Quest Saturday)
Saturday was missing. Replaced the partial listing with the full
7-entry table, the constant name, and a note that the map is also
unit-tested (assignDailyQuestion.test.ts). Future reader can now grep
the mode tag against both files at once.
Other Batch 4 claims verified clean:
- Schedule '0 23 * * *' America/Chicago, memory 512MiB, timeoutSeconds 300
in assignDailyQuestion source - all match.
- Document shape: questionId, date, assignedAt, expiresAt - all match the
create() payload (assignedAt is serverTimestamp, expiresAt is
timestampAt6PmCst(nextDay)).
- PAGE_SIZE = 300, ordered by __name__, startAfter pagination - matches.
- The daily_question allow create rule uses isCoupleKeyAnswerCreate OR
isSealedAnswerCreate - matches the manual's 'must match one of two
shapes' claim.
- The secure/{doc} read rule uses the partner-has-also-answered exists()
check - matches the Reveal flow claim.
- isSealedThreadAnswerCreate / Update have NO answerDate and NO
isRevealed field - matches the Thread questions claim.
The Argon2id parameter block said:
- memory: 46 MiB (46080 KiB)
- iterations: 3
- parallelism: 1
But the source constant in RecoveryKeyManager.kt is:
private const val ARGON2_MEMORY_KB = 46 * 1024
46 * 1024 = 47104, not 46080 (a slipped digit). 46080 KiB would be
45 MiB. The iOS-side docstring in CoupleEncryptionManager.swift
already says 46 MiB = 47104 KiB, so this is the Android-side drift.
Replaced the parameter block with the source-of-truth constant names
(ARGON2_MEMORY_KB, ARGON2_ITERATIONS, ARGON2_PARALLELISM) and the
correct KiB value (47104) so the next reader can grep the code.
Other Batch 3 claims verified clean:
- Encryption version table: EncryptionVersion.STRICT=2, acceptInviteCallable
hardcodes 2, throws if any of wrappedCoupleKey/kdfSalt/kdfParams is null.
- Tink AEAD wire formats: enc:v1:base64, sealed:v1:urlsafe-base64-no-padding,
keybox:v1:urlsafe-base64-no-padding, pub:v1:urlsafe-base64-no-padding,
sha256:urlsafe-base64-no-padding (43 chars). All match the source constants.
- AAD: FieldEncryptor uses coupleId; SealedAnswerEncryptor uses
coupleId|questionId|userId; both match.
- ECIES P-256: UserKeyManager uses
HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM;
ReleaseKeyEncryptor contextInfo is coupleId|questionId|senderUserId|recipientUserId.
- All 5 firestore.rules regex helpers match the manual's reference table.
- wrapReleaseKeyCallable reads the recipient public key from
users/{uid}/devices/primary (verified in function source).
- CoupleKeyStore persists Tink keyset handles in EncryptedSharedPreferences
(Keystore-backed) via SecurePreferencesFactory.
The Recovery phrase flow said RecoveryKeyManager.generateRecoveryPhrase()
draws from a 256-word list. Verified the actual list size in
RecoveryKeyManager.kt: the hard-coded WORDLIST array has 248 entries
(python re.findall over the array literal), and the iOS wordlist file
iphone/Closer/Crypto/Resources/wordlist.txt is 247 lines (last word
'real' with no trailing newline, so 248 entries). The '256' is also
wrong in the inline comment in RecoveryKeyManager.kt - a pre-existing
comment bug from before the R24 iOS port (IOS_E2EE_STATUS.md notes the
iOS SPEC.md originally said 256 too, corrected in 922364f). The Android
side never got the same comment fix.
Manual now says 248 with the source-of-truth pointers, and computes
the entropy as 248^10 (a quick sanity check) so the next reader doesn't
trust the wrong number on either side.
Other Batch 2 claims verified clean:
- All 5 Android files in the 'Key Android files' list exist.
- All 2 Cloud Functions files in the 'Key Cloud Functions' list exist.
- Rate limit: 1h window, 10 max, 25h TTL on invite_attempts - all match
ACCEPT_RATE_LIMIT_WINDOW_MS / ACCEPT_RATE_LIMIT_MAX / ACCEPT_ATTEMPT_TTL_MS
in acceptInviteCallable.ts and the fieldOverrides entry in
firestore.indexes.json.
- Couples doc model fields (id, userIds, inviteCode, createdAt,
streakCount, lastAnsweredAt, currentQuestionId, activePackId,
encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams) all match
the create() payload in acceptInviteCallable.ts. createdAt uses
FieldValue.serverTimestamp() (manual says 'server-side' - correct).
- EncryptionVersion.STRICT = 2 in EncryptionVersion.kt.
The Three platform split table at the top of the manual said iOS
'E2EE cross-compatibility not yet implemented' even though the iOS
E2EE section below it (and IOS_E2EE_STATUS.md) clearly state it is
code-complete for the schemaVersion 2 (couple-key) daily-answer path.
The schemaVersion 3 sealed-answer path is the part that is
infrastructure-gated (paired-CI vector run + macOS end-to-end).
Replaced the row text with a one-liner that points to the existing
iOS E2EE gap sub-section so future readers don't get the wrong first
impression from the overview.
Batch 1 of the Phase 3 plan (Engineering_Reference_Manual_Plan.md).
Repository layout Android/iOS/Cloud Functions all verified against
the live source - no other drift in this batch.
After pushing the Phase 2 sync, evidence-first review against the live
repo caught:
- Repository layout (Android) was missing directories that have shipped
since v0.2.1: widget/ (Glance Today, R29), core/firebase/, core/media/,
data/backup/ (R24 E2EE backup + partner-assist), data/local/{converters,
entity,mapper} subdirs, data/security/, domain/usecase/ (resolver +
GameSessionManager + SoloAnswerMigrator), top-level notifications/
package, and ui/{recap,messages,questions}/ + components/ subdirs.
- Repository layout (iOS) was missing Crypto/Resources/ (wordlist) and
the Crypto/ design notes (SCHEMA_VERSION_DECISION.md, SPEC.md).
- User doc field list + per-collection enforcement were missing the 3
R20 notif preferences: notifDailyReminder, notifStreakReminder,
notifPromotional. Verified all 5 are mirrored by
FirestoreUserDataSource.updateNotificationPrefs() and listed in the
firestore.rules user-doc allowlist.
- One copy fix: 'The B6d split of onGameSessionUpdate' -> 'The B6c
split' (B6c did the part-finished split; B6d was the logger finish).
Anchors verified clean (30/30). DEVELOPMENT_LOG.md is gitignored so
the local review notes stay on this box.
- Cloud Functions: rewrite the module tree for the v2 migration (B0-B6d,
2026-07-08); add options.ts (global v2 setGlobalOptions, Cloud Run CPU
quota workaround), log.ts, the shared push/quietHours/idempotency/
pruneTokens/time infra under notifications/, releaseKey/, backup/,
the dates/onDate* triggers, couples/aggregateOutcomes, and the new
sendStreakReminder + sendThinkingOfYouCallable. Replace the single
onGamePartFinished with the four per-game part-finished triggers
shipped in B6c. Note the webhook is not deployed (RevenueCat project
not yet created; export commented out in functions/src/index.ts).
- Daily question lifecycle: replace the 'picks a random' description
with the new server-authoritative, mode-aware, deterministic picker
that mirrors the client's DailyModeResolver; add a Server-authoritative
sub-section with the frozen DOW -> mode map and the daily_fun_mc
exclusion. Note the couple-scan pagination + unseeded-pool skip.
- Billing: add the Purchases.logIn(firebaseAuth.currentUser!!.uid)
identity link (commit b99a8338) and the typed BillingException mapping.
Cross-link to the Webhook reliability section for the not-deployed state.
- iOS: fix the Repository layout iOS Crypto/ block (R24 E2EE code ships
in it; no longer 'intentionally empty'); correct the 'pairing from iOS
fails' claim (works for schemaVersion 2 path; schemaVersion 3 is
infrastructure-gated per IOS_E2EE_STATUS.md); correct the 'iOS couples
have no recovery path' claim (R24 batch 2 added iOS recovery phrase).
- TOC + anchors: add the new sub-anchors; fix three pre-existing broken
anchors (r10, ios-android-sealed-answer-bridge, recovery-phrase-change
desync). 30/30 anchors verified clean with a GFM slug checker.
- New landmines: BANNER-LIFE-001 (R30 game banner lifecycle; the B6c
per-game split means a new game also needs a per-game monitor hook)
and FUNCTIONS-V2-DEPLOY (2nd-gen deploy / CPU-quota / Eventarc
propagation pattern, the 'Changing from an HTTPS function to a
background triggered function' error and the launch-time quota
increase to restore 1 vCPU + concurrency 80).
- Engineering_Reference_Manual_Plan.md: add Phase 2 status entry.
- New PremiumUnlockOverlay.kt — one-time 'Premium unlocked' celebration for both partners on couple-shared Premium activation. Driven off CouplePremiumChecker (not the push) so it surfaces for both wherever they are. Gated by persisted premiumUnlockCelebrated flag, auto-reset on lapse.
- New illustration_premium_unlock.png asset for the overlay.
- AppNavigation hosts the overlay at root alongside MessageBubbleOverlay.
- SettingsDataStore: new premiumUnlockCelebrated flag + setter.
- ThisOrThatScreen: theme-token fixes for A/B options, mood chips, versus badge, progress, ChoicePromptBackdrop — all read from MaterialTheme.colorScheme. Bumps dark-mode legibility.
- ConversationScreen: bump PendingMediaChip retry/dismiss IconButtons to 48dp touch targets.
- PlayHubScreen / ActivityScreen / HomeScreen / SubscriptionScreen / OnboardingScreen / PairPromptScreen / PaywallScreen / LocalQuestionContent / OutcomeCheckInDialog / ChatComponents: assorted R13 polish.
- firestore.rules (n/a this batch), SettingsRepository, manual: doc + flag wiring.
- Manual: new C-DARK-UI-001 + C-ART-EDGE-002 landmines, Premium-unlock-modal pattern note.