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.
Reset the plan file from the v0.2.1 + Phase 2 single-pass state to a new
Phase 3 batch plan covering a full evidence-first re-review of the manual.
13 batches, one per major section of docs/Engineering_Reference_Manual.md.
This file is the working log for the Phase 3 review; per-batch findings
get appended as each batch completes.
Batch 1 (iOS platform row 'E2EE not yet implemented' was stale) has
already shipped as ef5a2331 - this commit just lays out the plan for
batches 2-13.
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.
Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.
RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.
Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
stable API surface across 8->10 per RevenueCat's own migration notes for the calls
this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
from a real failure via PurchasesTransactionException.userCancelled, using a shared
PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
for offering-load failures) so PaywallViewModel resets silently instead of surfacing
the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
a snackbar. Previously there was zero user-facing feedback on a real purchase failure
beyond the loading spinner disappearing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The R29 games-review section had drifted — most of it shipped in R30 (batches C1–C4) and
went live with today's deploy. Reconciled against the git record, not memory:
- Shipped block added: server daily-question pool seeded + server-authoritative/mode-aware/
deterministic selection (f2321d35, kills DQ-MISMATCH + cross-timezone splits); banner
lifecycle BANNER-LIFE-001 (C1); retention set — HowWell role-swap rematch, NextBeatCard
return-tomorrow beat, DS seen-question memory, deterministic Date Match shuffle +
skip-swiped, daily_fun_mc excluded from HowWell pool, Challenges day-N+1 teaser (C2).
- Still-open list trimmed to what's genuinely open: the game-engine/replay/composable
dedup refactors, premium-check helper, GameConstants, DispatcherProvider, error-surfacing,
retention analytics events (the one unbuilt retention piece), the daily_fun_mc sex retag
(now DS-only hygiene post-C3), npm audit (firebase-admin transitives).
- partner_joined_game push: "pending deploy" → BUILT + LIVE (2026-07-08); noted the
abandoned-session false-finish guard that shipped in the same deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deployed 2026-07-08: all 35 functions in-place on nodejs22 (incl. gen1 onUserDelete),
CI pins matched, verified via functions:list + callable cold-boot smoke + clean logs.
Recorded why nodejs24 was rejected (2nd-gen only; gen1 onUserDelete blocks a codebase-wide
24) and the next forced-bump horizon (Node 22 decommissioned 2027-10-31).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Node 20 was deprecated 2026-04-30 and deploys are blocked after 2026-10-30. nodejs22 is GA
for BOTH gens (deprecation 2027-04-30) — required because the runtime is codebase-wide and
onUserDelete stays gen1. nodejs24 was checked per plan and rejected: 2nd-gen only.
- functions/package.json engines.node 20 → 22 (the deploy-facing change; dist unaffected)
- .github/workflows/backend-ci.yml: both setup-node pins 20 → 22 so CI matches prod
Verified pre-deploy: tsc clean, 70 tests green, emulator discovery loads all 35 / 0 errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploys kept failing container healthchecks with "Quota exceeded for total allowable CPU
per project per region" even in small batches: v2 gives every instance a full vCPU (needed
for concurrency 80), and ~35 services at 1 vCPU exceeds this new project's default Cloud Run
CPU quota under any accounting. cpu:'gcf_gen1' restores the gen1 fractional tiers
(256MiB → 1/6 vCPU) — a 6x smaller footprint, identical to how these functions ran on gen1.
Concurrency must be 1 with cpu<1; costless at dev scale. At launch: raise the quota, drop
these two options to restore full-vCPU concurrency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2nd-gen deploy failed with "Quota exceeded for total allowable CPU per project per region":
each function is a Cloud Run service and the regional CPU-allocation quota is charged as the
sum of (maxInstances × vCPU) across all functions. At maxInstances 20 × ~34 v2 functions =
~680 vCPU, over this new project's default (~560). Dropping to 5 → 170 vCPU, well under.
5 instances × ~80 concurrent requests still serves ~400 in flight — fine pre-launch. For
production, request a Cloud Run CPU quota increase and raise this back up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RevenueCat isn't set up, so exporting revenueCatWebhook forced a Secret Manager entry:
defineSecret('REVENUECAT_SIGNING_KEY') runs at module load, and Firebase validates every
declared secret across the whole codebase at deploy time (even functions excluded via --only),
failing with "no latest version of the secret". Comment out the export so the file isn't loaded
during discovery — no secret, no validation. revenueCatWebhook.ts (already migrated to v2) is
untouched; re-enable by uncommenting the export, seeding the real key, and deploying it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate the last console.* call sites (the shared helpers entitlementLogic.ts and
pruneTokens.ts) to firebase-functions/logger, completing the structured-logging sweep.
Zero console.* remain under functions/src.
Final verification of the whole v1→v2 migration:
- tsc clean under firebase-functions v7.2.5; 70 jest tests green.
- Emulator discovery loads all 36 functions in us-central1 with 0 errors and no
outdated-SDK warning; onUserDelete remains a v1 auth trigger, the rest are v2.
- Grep gates clean: no functions.https.onCall/.firestore.document/.pubsub.schedule,
no context.auth/app/params, no console.* in src, no messaging.send outside push.ts,
no raw FCM tokens in any log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the single broad onGamePartFinished (couples/{coupleId}/{gameType}/{sessionId}
wildcard, which fired a no-op invocation on every write to ANY couple subcollection) into
four narrow, explicitly-pathed triggers sharing one handler:
onThisOrThatPartFinished, onWheelPartFinished, onHowWellPartFinished, onDesireSyncPartFinished.
Behavior is identical for the four game collections; the spurious invocations for
messages/reactions/etc. are eliminated. (Background triggers have no client name dependency;
the old export is dropped and the four deploy fresh — the deploy runbook already accounts for
this.)
onGameSessionUpdate: move the `!change.after.exists` deletion guard ABOVE its four reads
(session/couple/userA/userB) so a delete/no-op event returns before doing any reads. The
delicate exactly-once claim-flag logic is otherwise untouched.
Build clean; 70 tests green. Discovery loads all four split triggers as v2 in us-central1
(36 functions total); old onGamePartFinished gone. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
assignDailyQuestion and aggregateOutcomeStats previously did an unbounded
db.collection('couples').get() (loading every couple into memory), and aggregate did a
serial outcomes.get() per couple (O(couples) round-trips). Both now paginate the couple
scan (orderBy __name__ + startAfter, 300/200 per page — no custom index needed):
- assignDailyQuestion: each page's create() writes fan out with the burst bounded to a page
instead of all couples at once; ALREADY_EXISTS stays the idempotent no-op.
- aggregateOutcomeStats: reads each page's outcomes in parallel instead of serially.
couples.length still counts every couple, so the aggregate windows + totalCouples are
unchanged (pure aggregate() helper and its tests untouched).
This is the one behavior-touching improvement flagged in the plan; the 512MiB/300s resource
options from B2 remain the safety net. Build clean; 70 tests green. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump firebase-functions ^5.1.0 → ^7.2.5. The whole codebase now uses the v2 API
(firebase-functions/v2/*) with onUserDelete explicitly on firebase-functions/v1, so the
v6/v7 removal of the root v1 namespace is a no-op for us. firebase-admin unchanged (v7 does
not peer-require a bump).
The plan assumed v6 was latest; the actual latest is v7.2.5 (v7 released after the plan was
written). Verified under v7: tsc clean, 70 tests green, emulator discovery loads all 33
functions in us-central1 with zero errors and the "outdated firebase-functions" warning gone
(v6.6.0 still tripped that warning).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B4 — revenueCatWebhook: functions.https.onRequest → firebase-functions/v2/https onRequest,
with REVENUECAT_SIGNING_KEY bound as a Secret Manager secret via defineSecret (injected into
process.env, so the Ed25519 verify + process-before-ack/500-retry logic is unchanged). Request
type retyped to the v2 Request; console → logger. The key must be seeded in Secret Manager at
deploy (runbook) — it isn't in the repo.
B5 — onUserDelete: kept on the v1 API (2nd gen has no auth.user().onDelete), imported explicitly
from firebase-functions/v1 and wrapped in runWith({ timeoutSeconds: 300, memory: '512MB' }) for
its dual recursiveDelete + Storage sweep. Adopts shared getUserTokens/sendPushToUser + logger;
preserves the original "only notify if the partner has a live token" behavior.
(wrapReleaseKey's HttpsError→v2 swap already landed in B3.)
Build clean; 70 tests green. dist rebuilt. Still on firebase-functions v5.1.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate all callables off functions.https.onCall to firebase-functions/v2/https onCall:
createInvite, acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou, checkDeviceIntegrity, syncEntitlement, assignDailyQuestionCallable,
wrapReleaseKey. context.auth/app → request.auth/app, data arg → request.data. The 8
client-hardcoded callable names are preserved verbatim (verified via emulator discovery).
The manual `if (!request.app)` App Check check is a 1:1 port (no enforceAppCheck switch).
Hardening folded in:
- acceptInviteCallable: await the previously fire-and-forget partner_joined push (gen 2
freezes the instance after the response) — still swallows push errors so a failed push
never fails the accept.
- checkDeviceIntegrity: 10s timeout on the Play Integrity client.request so a hung upstream
can't pin the instance (fail-closed catch already handles the throw); memory 512MiB.
- wrapReleaseKey: memory 512MiB (tink); HttpsError swapped to v2; lazy tink require + graceful
failure preserved.
- Error mapping with `if (e instanceof HttpsError) throw e` re-throw guard around the risky
DB sections in acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou — raw errors map to a clean 'internal' without masking intentional codes
(resource-exhausted rate limits, permission-denied, etc.). leaveCouple's best-effort
recursiveDelete sweep now swallows errors (the transactional leave already succeeded).
- Adopt shared sendPushToUser()/logger; remove copied token readers + plaintext token logging.
Delete dead placeholder callables notifications/reminders.ts (sendDailyQuestionReminder,
sendPartnerAnsweredNotification) — no client caller; wrote sent:false rows nothing consumed.
Build clean; 70 tests green; discovery loads all callables as v2 in us-central1. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate all scheduled jobs off functions.pubsub.schedule().onRun() to
firebase-functions/v2/scheduler onSchedule({ schedule, timeZone, ...opts }, handler):
sendChallengeDayReminders, unlockDueMemoryCapsules, sendDailyQuestionProactiveReminder,
sendStreakReminder, sendReengagementReminder, assignDailyQuestion (scheduled export),
aggregateOutcomeStats, scheduledOutcomesReminder.
Hardening folded in:
- Fan-out isolation: Promise.all → Promise.allSettled in dailyQuestionReminder (outer+inner),
reengagement, gameRetention (both jobs), scheduledOutcomesReminder — one bad couple can no
longer abort a whole run. streakReminder / assignDailyQuestion already isolated.
- Resource options: assignDailyQuestion + aggregateOutcomeStats memory 512MiB + timeout 300s
(they iterate all couples); the four fan-out reminders get timeout 180s.
- Adopt shared sendPushToUser()/logger everywhere; remove five copied getUserTokens() and the
copied send/prune blocks (no plaintext token logging remains here).
- Consolidate duplicated date/time helpers into notifications/time.ts (chicagoDateKey, toMillis),
replacing streakReminder's + scheduledOutcomesReminder's per-file copies.
assignDailyQuestion.ts callable export stays v1 for now (migrates in B3); its tested CST helpers
are untouched. Scanner pagination for assignDailyQuestion/aggregateOutcomes is deferred to B6.
Build clean; 70 tests green (tested pure helpers preserved). dist rebuilt. Still on v5.1.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groundwork for the v1→v2 Cloud Functions migration; no function is migrated yet
(all 35 still load as v1, verified via emulator discovery).
- options.ts: setGlobalOptions({ region: 'us-central1', maxInstances: 20 }), imported
first in index.ts so it applies before any v2 function is defined. Region pin is
load-bearing — the Android client uses the default region.
- notifications/push.ts: single canonical getUserTokens() + sendPushToUser() that
batches via messaging.sendEachForMulticast() and prunes dead tokens, to replace the
~10 copied token readers and ~19 copied send/prune blocks in later batches.
- log.ts: firebase-functions/logger re-export + redactToken() (FCM tokens are secrets).
- push.test.ts: 9 unit tests (token merge/dedupe, BatchResponse→dead-token mapping,
send/prune/no-op/whole-batch-failure paths). 67 tests green.
firebase-functions stays at v5.1.1 for the migration (supports both the root v1 API and
the /v2 subpaths); bump to v6 is deferred to the final batch once nothing references the
root namespace, so the build stays green at every step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-account emails/uids/passwords now live in qa/fixtures.local.md (gitignored);
qa/README.md carries only a pointer. Credentials must never be committed or pushed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The daily reveal chip used displayCategoryName() (a different surface than the
Home pill fixed earlier), so it still showed 'Daily Fun Mc'. Fixed centrally in
the shared helper. Verified live: reveal now shows 'Daily Fun'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace pickRandomQuestionId (empty pool → unresolvable q_default_daily fallback)
with pickDailyQuestionId(date): computes today's weekday mode (mirrors the client
DailyModeResolver.DOW_DEFAULTS, FROZEN) and deterministically indexes the free
daily pool by epochDay % poolSize — the SAME question the free client would pick,
now assigned server-side.
Fixes two things at the root:
- The daily 'questions' pool was empty, so every couple got q_default_daily, which
the client can't resolve → each device fell back to its own local selection
(the DQ-MISMATCH-001 class; the client half was pinned to the free pool in C1).
- Partners in different time zones computed different device-local weekdays and got
different questions; a single server-assigned id makes both devices identical.
Firestore 'questions' seeded with the 75 free daily_fun_mc weekday questions (ids
match the client asset DB so getQuestionById resolves them). Pool fetched once and
filtered in memory — no composite index needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
partner is still playing, not once it's your turn) reusing the generic
thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
Toast on result incl. friendly rate-limit copy. Verified live: nudge →
partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
('You matched on N of M') and the How Well score ring Canvas ('You guessed N
of M correctly') — both were split/Canvas visuals invisible to TalkBack.
Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NextBeatCard: a self-contained 'what's next' card under every game's results
CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
→' when today's daily is unanswered, else a 'Day N together' streak note. Own
@HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
guess you', starting a new round where they become the subject (Newlywed-style
reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
same first card') + skip already-swiped ideas so the deck resumes past them
instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
(1869 real prediction questions remain); DAO method has no other caller.
Unit suite green; assembleDebug clean; live-verified on the emulator pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
(platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
per-uid, instead of an in-memory singleton that died on process death and made
CreateProfile re-ask for the birth date after any restart mid-onboarding
(verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
so the auth-token/Firestore-rules race the old comment guarded against doesn't
apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
of spoiling the next prompt the moment today's step is marked done. +2
ChallengeStateMachine tests.
Unit + functions suites green; verified live on the emulator pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Challenges catalog: hide the 🔒 Premium badge once the couple has premium
(A-003b; matches the Play hub's showPremiumBadge pattern). Verified live
both directions under an authorized grant/revoke cycle.
- Game banner lifecycle (BANNER-LIFE-001): entering a session's screen now
consumes any banner pointing at it (GamePromptController.consumeForSession
wired into ActiveGameSessionMonitor.enter), and activity from a DIFFERENT
session may replace a stale persistent banner. Verified live: no banner on
reveal; stale RESULTS banner replaced by a new session's prompt.
- Waiting/join screen: says 'Your turn — {name} already played their part'
for the non-starter once a first part landed (new partHasFinished mapped
from partFinishNotifiedAt; completedByUsers only fills at reveal). Session
observe mapping now also carries completedByUsers/joinedByUsers.
- How Well results: matched-row colors are now a theme-aware container+content
pair (dark mode was near-invisible: fixed pale-green container under
onSurfaceVariant text).
- Date Match: top card fully opaque — next card's text no longer bleeds
through (was alpha 0.96).
- functions: don't send 'X finished — see your results!' for abandoned/quit
sessions (status flips to completed with empty completedByUsers; a real
completion always has both uids). Found live when a quit triggered a false
banner. Needs deploy (bundled with C4).
Unit + functions suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Delete unused ui/theme/Color.kt (zero refs; live palette is Theme.kt).
- Remove orphaned EditProfileScreen wrapper composable (no route references it;
AccountScreen embeds EditProfileContent, which stays).
- Debug-gate ArtPreviewScreen + PairedHomePreviewScreen out of the release nav
graph (BuildConfig.DEBUG).
- Collapse duplicate wheel_history route into game_history (same screen; wheel
surfaces now navigate to Past Games directly).
- CouplePremiumChecker: inject AuthRepository instead of raw
FirebaseAuth.getInstance() (finishes the in-flight auth-DI refactor for
non-data-layer call sites).
- Copy/UI polish: 'It's a match!' (matches the push copy), internal 'mc' token
never renders in the Home daily pill ('Daily Fun Mc' → 'Daily Fun'),
welcome/splash privacy tagline no longer clips mid-sentence (maxLines 4 +
ellipsis, AUTH-TRUNC-001).
Unit + functions suites green; assembleDebug clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Game answer listeners (ToT/HowWell/DesireSync): close(err) instead of
swallowing snapshot errors, + .catch in each VM's observeReveal surfacing a
retryable ERROR (GameCopy.SYNC_ERROR + retrySync re-attach) — games no longer
hang on WAITING forever on listener failure (GAME-HANG-001; matches the
Wheel/Capsule sources' established pattern).
- Daily question: paired fallback pool is now premium-INDEPENDENT so both
partners always resolve the same deterministic question; viewer-premium pools
broke the couple contract when entitlement state differed or flipped mid-day
(DQ-MISMATCH-001, reproduced live: partners answered different questions).
- Daily reveal: humanize raw option-id fallbacks so slugs never render
(DQ-SLUG-001, e.g. 'fake_awards_should_be_mandatory').
- assignDailyQuestion.ts: replace hardcoded CST_OFFSET_HOURS=-6 with DST-safe
Intl America/Chicago helpers (DST-001) + 5 regression tests (CDT/CST labeling,
6PM reveal instant, spring-forward round-trip).
Verified live 2-device: identical daily question on both partners post-fix;
ToT full loop 5/5 reveal regression clean. Unit 244 + functions 58 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the applied standardizations (state collection, whole-data-layer
Tasks→await, version catalog, TS payload typing, 7/8 ViewModel-Firebase
extractions) and the four deliberately-scoped follow-ons (HomeViewModel metadata
listeners, color tokens, Functions v2, catch-unknown) with the reason each needs
its own verification loop rather than a broad sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DailyQuestionViewModel and PartnerHomeViewModel each held a raw
db.collection(...).addSnapshotListener on the partner's answer doc (just reading
snapshot.exists()). Replace with the existing, proven
FirestoreAnswerDataSource.observeAnswerForUser(...) Flow collected in a
viewModelScope Job (auto-cancelled; previous run cancelled on restart) — dropping
the FirebaseFirestore injection from both. Same behavior (exists → non-null),
now through the data layer.
Verified live: daily question shows the correct "waiting for your partner" state
via the new Flow; compiles + unit suite green.
HomeViewModel's two listeners use MetadataChanges.INCLUDE + inline analytics and
drive the core reveal — left as a scoped follow-on (needs a metadata-aware
data-source Flow + two-device real-time verification). See docs/standardization-review.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five ViewModels reached into the FirebaseAuth.getInstance() static singleton for
the current user id; switch them to the injected AuthRepository.currentUserId
(BucketList, QuestionThread, DateBuilder, MessagesInbox, Conversation). Removes
the Firebase-in-presentation-layer coupling and makes them testable. No behavior
change; compiles + unit suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change the 5 https.onCall handlers from `data: any` to
`data: Record<string, unknown>`, so payload fields are `unknown` and must go
through the existing validators rather than being implicitly-typed. No behavior
change (every field was already validated); tsc + 53 function tests green.
Left as-is deliberately: `catch (err: unknown)` narrowing (churn, marginal) and
the untyped Tink handles in wrapReleaseKeyCallable (the crypto lib ships no types).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>