Commit Graph

735 Commits

Author SHA1 Message Date
null f336c91731 docs(manual): batch 6 - fix broken iOS-Android sealed-answer bridge anchor (→ slug mismatch) 2026-07-11 16:25:36 -05:00
null 690b567f82 docs(plan): phase 4 batch 5 no-op - qa_push.js fix is internal to the QA helper 2026-07-11 16:24:20 -05:00
null fcee89912f docs(plan): phase 4 batch 4 no-op - copy catalog + string cleanup don't touch the manual 2026-07-11 16:24:04 -05:00
null dc624ebb21 docs(manual): batch 3 - flip per-module bullet for billing webhook to exported 2026-07-11 16:23:38 -05:00
null 0b92632973 docs(manual): batch 2 - RevenueCat 10.13.0 + HMAC-SHA256 webhook in tree + gitignore entry 2026-07-11 16:23:16 -05:00
null c60fa958a5 fix(qa): qa_push.js picks newest FCM token, not docs[0]
After an APK reinstall a device registers a fresh FCM token alongside stale
ones (~33h). The smoke harness grabbed docs[0] (unordered) and could hit a
stale token -> FCM 'Requested entity was not found' -> false smoke FAIL.
Now sort fcmTokens by updatedAt and try newest-first across all tokens,
skipping dead ones, mirroring the app's sendToUser + pruneTokens behavior.

Also file two R31 QA-env observations to Future.md (add the RevenueCat test_
key to local.properties so QA can drive the live paywall; FCM token churn note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:06:07 -05:00
null 89d06eeb7d fix(billing): correct RevenueCat webhook auth to HMAC-SHA256 + enable export
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>
2026-07-09 03:43:59 -05:00
null 48217dd716 chore(security): gitignore revenucat_secret_api.md (holds a RevenueCat secret key)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:43:43 -05:00
null 8cb23ea244 build(billing): bump RevenueCat SDK 10.12.0 → 10.13.0
Routine SDK patch bump. Resolves + compileDebugKotlin clean; no API changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:45:36 -05:00
null 2c3c2df8c4 fix(copy): reconcile divergent premium benefit lists + drop inaccurate claim
The paywall and subscription screens showed two different premium benefit lists (centralizing
into CloserCopy surfaced the drift). Unified to a single CloserCopy.premiumBenefits used by both.

Also an accuracy fix: dropped "Exportable memories" — it directly contradicts the app's own
privacy copy ("Closer does not currently offer a data export", strings.xml privacy_no_export_body),
i.e. a benefit the app explicitly does not provide. Remaining items are all verified real features
(QuestionComposer, Connection Challenges, Desire Sync, Memory Lane, date planning, answer history).

compileDebugKotlin clean; single source of truth for both surfaces going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:50:29 -05:00
null 42fba641f1 chore(cleanup): remove the unused server/ Express backend (Tier 3)
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>
2026-07-09 00:48:02 -05:00
null 23cd213953 refactor(copy): Slice 1 — dedup age-gate copy into CloserCopy.AgeGate
The 18+ min-age error was triplicated verbatim (SignUpViewModel, CreateProfileViewModel ×2);
consolidated to CloserCopy.AgeGate.ageError(minAge). Also lifted the two disclaimer variants
(kept distinct: brief on sign-up, with-reason on the DOB step — not unified) and the duplicated
"Please enter your date of birth." prompt. 6 call sites now source from the catalog; copy is
byte-identical (no behavior change), compile clean.

Adds docs/CopyMigration.md — the migration plan, scoped (after a gap review) to the ~15-25
brand-voice/duplicated lines, NOT all ~281 UI literals: the bulk stays inline until the single
i18n → strings.xml pass, since double-migrating to a Kotlin catalog first is wasted work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:39:04 -05:00
null 0195564d25 docs(readme): refresh ready-state home screenshots 2026-07-09 00:30:27 -05:00
null 4103c43393 docs(manual): drop dead references to files removed by the 21392ec2 dead-file sweep
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.
2026-07-09 00:29:26 -05:00
null 51efff3f78 refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).

First slice (highest-value, monetization surface):
- PaywallScreen + SubscriptionScreen voice copy (benefit lists, headlines, value props,
  "Thank you for supporting Closer", couple-shared taglines) now source from CloserCopy.
- Generic chrome ("Continue", "Restore", "Manage subscription", error-retry) intentionally
  stays inline — it isn't brand voice.
- Surfaced real copy drift: the paywall and subscription benefit lists differ (swap two
  items + reorder) — co-located and flagged in CloserCopy.kt for a copy decision, left
  verbatim (not silently unified).

compileDebugKotlin clean; no behavior change (copy identical, just relocated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:26:15 -05:00
null 59e033692b chore(cleanup): purge 55 unused string resources
strings.xml had 127 entries but the app renders copy from hardcoded Compose literals
(only ~4 files use stringResource) — so ~half of strings.xml was a resource catalog that
was pre-created for the pairing/home/partner-home/settings-nav screens and never wired up.

Removed the 55 entries with zero R.string./@string references anywhere (kt/xml/manifest),
including whole dead sections (Settings nav labels, all Pairing subsections, Home screen,
Partner home). Kept everything actually referenced: app_name, today_widget_description,
common actions in use, and the Appearance/Notifications/Account/Privacy sections.
Resources compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:20:32 -05:00
null 21392ec2f3 chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)
Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).

Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
  NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
  (entitlement state is read as Firestore booleans, not this model)

Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
  referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files

Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:10:40 -05:00
null a8819650d2 docs(store): refine banner card icon balance 2026-07-09 00:04:16 -05:00
null 2aa1157fe1 docs(store): rebalance README banner card icons 2026-07-08 23:57:51 -05:00
null 19b2ce8918 docs(brand): retire unreferenced generated-art feature graphics; single source of truth
Removed the two internal closer_app_explainer_feature.{png,svg} and
closer_e2ee_privacy_feature.{png,svg} files from docs/brand/generated-art/.
They were unreferenced banner-like art referenced by no store/README
doc, and the new docs/store/feature-graphic-1024x500.png (the
white-keyhole logo heart, 122aef7b) is the single source of truth for
README/store banner work.

docs/NameChange.md §5 updated to reflect the retirement (was 'OPTIONAL,
not store-facing'; now 'Retired ... so there is a single source of
truth') and points to the new feature-graphic as the canonical banner.
2026-07-08 23:51:10 -05:00
null 122aef7b88 docs(store): use white-keyhole logo heart in banner 2026-07-08 23:48:39 -05:00
null cbb42a6e5c docs(plan): Phase 3 complete - 13 batches, 11 issues found and fixed
Completed the full evidence-first re-review of
docs/Engineering_Reference_Manual.md started in the previous commit.
13 batches, 11 issues found and fixed (Batches 11 and 13 were clean),
all committed individually per the standing one-commit-one-batch rule.

Final verification: 30/30 internal anchors resolve cleanly with a
GFM-compliant slug checker; 0 broken TOC links.

Batches 1-13 commits on dev:
  ef5a2331  batch 1  iOS platform row E2EE not-yet-implemented claim
  1b36eba6  batch 2  recovery phrase wordlist size 256 -> 248
  0af6f24a  batch 3  Argon2id memory 46080 -> 47104 KiB
  5e292e66  batch 4  DOW mode table was missing Saturday
  5f7c785f  batch 5  3 missing rule helpers + isPublicKey regex +
                     releaseKeys sender-read fix
  aa356148  batch 6  Cloud Functions handler types table was imprecise
  ceb98f4a  batch 7  iOS DefaultEntitlementChecker actor doesn't
                     exist; gated-features list was wrong
  29cc39aa  batch 8  How-to-gate section header + game-push flag
                     claim (notificationsSent map -> 4 Timestamps)
  9330c0c4  batch 9  Tink template name SHA128 -> SHA256
  22c816ad  batch 10 RevenueCat version 8.20.0 -> 10.12.0 + Glance
  b3725463  batch 11 engineering conventions verified clean (no edits)
  80751f82  batch 12 A-201 landmine heading restored
  c5ead4cb  batch 13 where-to-look-first verified clean (no edits)

The full table of issues + commit SHAs is appended to this plan file
for the next session's reference.
2026-07-08 23:33:59 -05:00
null c5ead4cb83 docs(manual): batch 13 review - Where to look first verified clean, no edits
Every file path in the 'Where to look first' reading list resolves
to a real file in the current source:
  1. README.md
  2. PROJECT.md
  3. app/src/main/java/app/closer/crypto/EncryptionVersion.kt
  4. firestore.rules
  5. functions/src/index.ts
  6. functions/src/couples/acceptInviteCallable.ts (also verified:
     const encryptionVersion = 2 - unconditional assignment)
  7. functions/src/questions/assignDailyQuestion.ts
  8. app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt
  9. app/src/main/java/app/closer/crypto/SealedRevealManager.kt
  10. app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt
  11. app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt
  12. app/src/main/java/app/closer/notifications/GamePromptController.kt
  13. app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt
  14. app/src/main/java/app/closer/core/navigation/AppNavigation.kt
  15. iphone/Closer/Services/FirestoreService.swift
  16. iphone/ARCHITECTURE_AUDIT.md

All 16 files exist. The 'DST-quirky date math' note on
assignDailyQuestion still applies (the function uses
CST_OFFSET_HOURS = -6 without DST awareness - documented in the
Date math - known DST bug sub-section under Daily question lifecycle).

No edits required for this batch.
2026-07-08 23:33:22 -05:00
null 80751f82fb docs(manual): batch 12 review - restored A-201 landmine header
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.
2026-07-08 23:32:49 -05:00
null b3725463ec docs(manual): batch 11 review - Engineering conventions verified clean, no edits
Every claim in the Engineering conventions section (Git, Files that
must never be committed, Versioning, Naming, Logging, Error handling,
Testing, Privacy and data retention) cross-checks against the live
source. No claims were wrong, no edits required for this batch.

Verified:
- Git remote: ssh://forgejo/null/Closer.git
- BuildConfig.DEBUG guard around android.util.Log: confirmed in
  QuestionJsonParser.kt (2 log sites, both wrapped)
- Cloud Function log prefix pattern: confirmed in acceptInviteCallable.ts
- HttpsError (not plain Error) for callable errors: confirmed
- onUserDelete cascade cleanup: confirmed
- Manual QA docs: docs/qa/private-mvp-checklist.md and
  docs/qa/ui-review.md both exist
- HISTORY.md changelog + app/build.gradle.kts version drift already
  flagged in Section 10 Build and release
2026-07-08 23:31:49 -05:00
null 22c816add1 docs(manual): batch 10 review — RevenueCat version was 8.20.0, now 10.12.0
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.
2026-07-08 23:30:43 -05:00
null 9330c0c452 docs(manual): batch 9 review — Tink template name was SHA128 (typo), should be SHA256
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.
2026-07-08 23:29:37 -05:00
null 29cc39aa42 docs(manual): batch 8 review — restored 'How to gate a new feature' section + game-push flag claim was wrong
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.
2026-07-08 23:28:23 -05:00
null ceb98f4abd docs(manual): batch 7 review — iOS 'DefaultEntitlementChecker actor' doesn't exist; gated features list was wrong
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.
2026-07-08 23:27:16 -05:00
null aa35614840 docs(manual): batch 6 review — handler types table was imprecise (mixed onCreate/onWritten/onUpdate)
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).
2026-07-08 23:25:34 -05:00
null 5f7c785f5c docs(manual): batch 5 review — 3 missing rule helpers + releaseKeys sender-read fix
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).
2026-07-08 23:24:38 -05:00
null 5e292e66ea docs(manual): batch 4 review — DOW mode table was partial (missed Saturday)
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.
2026-07-08 23:22:47 -05:00
null 0af6f24ab8 docs(manual): batch 3 review — Argon2id memory was 46080 KiB but the code is 47104
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.
2026-07-08 23:21:35 -05:00
null 1b36eba692 docs(manual): batch 2 review — recovery phrase '256-word list' was wrong (it's 248)
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.
2026-07-08 23:20:16 -05:00
null f5194a4453 docs(plan): rewrite Engineering_Reference_Manual_Plan for Phase 3 (13-batch re-review)
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.
2026-07-08 23:18:54 -05:00
null ef5a2331fd docs(manual): batch 1 review — iOS platform row 'E2EE not yet implemented' was stale
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.
2026-07-08 23:18:33 -05:00
null 6f4f98f0ad docs(store): sharpen privacy promise in README banner 2026-07-08 23:11:36 -05:00
null d31b58d238 docs(manual): review pass — missing dirs + 3 notif prefs + B6c/B6d mix-up
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.
2026-07-08 22:10:40 -05:00
null 1242799001 docs(store): rename marketing assets to Closer Couples 2026-07-08 22:01:10 -05:00
null 213cfddb16 docs(manual): sync Engineering Reference Manual with post-R30 / v2-functions codebase
- 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.
2026-07-08 21:56:44 -05:00
null b47302d6b7 docs(readme): refresh daily question screenshots 2026-07-08 14:53:12 -05:00
null 53a821a80d fix(home): use people art for daily question card 2026-07-08 14:39:33 -05:00
null 6bd28d34e4 fix(home): use daily question art before answers 2026-07-08 14:29:12 -05:00
null b99a83388e fix(billing): link RevenueCat identity to Firebase auth + purchase UX
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>
2026-07-08 14:19:58 -05:00
null 661f391582 docs: reconcile Future.md against R30 + the 2026-07-08 functions v2 deploy
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>
2026-07-08 03:53:13 -05:00
null 2d1d89ecc7 docs: mark Node 22 runtime bump done in Future.md
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>
2026-07-08 03:47:47 -05:00
null 3cd3195e3f build(functions): bump runtime Node 20 → 22 (Node 20 decommissioned 2026-10-30)
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>
2026-07-08 03:42:18 -05:00
null 805ef9f2e7 docs: record Cloud Functions v2 deploy follow-ups in Future.md
New "Backend — Cloud Functions v2 follow-ups" section (deployed 2026-07-08):
- launch-time quota work: raise Cloud Run CPU quota, then drop cpu:'gcf_gen1'/concurrency:1,
  revisit maxInstances, add minInstances to cold-start-sensitive callables
- ops recovery pattern for orphaned https strays from failed trigger creates
- Node 22 runtime bump before the 2026-10-30 Node 20 decommission
- RevenueCat webhook re-enable steps (secret seed, uncomment export, delete dormant v1 first)

Also refresh the npm-audit note: 9 moderates remain post v7 bump — firebase-admin transitive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 03:24:06 -05:00
null 7d015e3ab9 fix(functions): gcf_gen1 fractional CPU to fit the Cloud Run CPU quota
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>
2026-07-08 03:04:26 -05:00
null 9b62957052 fix(functions): lower global maxInstances 20→5 to fit the Cloud Run CPU quota
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>
2026-07-08 02:44:06 -05:00