The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.
The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:
phase 1 publish the new phrase (enc:v1: under the couple key) + phraseGeneration
phase 2 each device confirms it can read it (ack)
phase 3 once BOTH acked, re-wrap under it + phraseWrapGeneration
Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.
Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.
I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):
- phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
when the field never existed — couples are created without it — so the first
phrase change of any couple was denied AFTER the UI had already shown the user
their new phrase. The dud-phrase outcome, relocated. Both sides now default.
- `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
one-field write passed (an unchanged wrap short-circuits the wrap clause), and
the partner's client trusts that field as proof the wrap moved — so one write
made them overwrite their working phrase with one that unwraps nothing.
Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
- phase 3 never checked the acks server-side (client-only), so a client could
complete before the partner had the phrase — exactly what the handshake exists
to prevent. The rules now require both members' acks at the current generation.
A fourth, caught by the tests themselves: the predicate didn't require the wrap
to actually change, so advancing the generation alone still passed.
Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.
Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dead code isn't inert — this carried a live-looking E2EE encryptor and a rules
block, inviting the next engineer to extend a surface nothing reads (which is
exactly how N-002 happened: writes into a collection with no reader). Removed,
all verified caller-dead first: the Firestore preference methods + mapper on
FirestoreDatePlanDataSource, the repository preference surface (interface +
impl + the assemblePlanSuggestion placeholder, whose body was literally
`val x = /* comment */` swallowing a return — dead AND weird), the
DatePlanPreference/DatePlanSuggestion domain models, DatePlanPreferenceDao +
its DI provider + the AppDatabase accessor, and the firestore.rules block
(collection is now default-deny, strictly safer; stray pre-R15 docs are
orphaned — launch-checklist note in Future.md). DateBuilderViewModel's
vestigial savePreference() renamed createPlan() — it has created real PLANNED
plans since the N-002 fix.
The one deliberate KEEP: DatePlanPreferenceEntity stays registered in
AppDatabase. Room's identity hash is computed from the SCHEMA, and the DB ships
via createFromAsset with no migrations — dropping the entity changes the hash
and crashes every install at first DB open. The entity now carries a loud KDoc
saying exactly that (and the AppDatabase comment points at it), so nobody
"cleans it up" without regenerating the asset DB. Removing DAOs/methods is
hash-neutral; proven live: fresh install on-device, DB opens clean, DB-served
content renders.
Also repaired collateral from my own removal script: it over-cut plansRef
(caught by compile, restored verbatim) — and the repo impl no longer injects
the same datasource twice under two names.
Live post-removal: Create Plan saves without error or PERMISSION_DENIED (the
surviving savePlan path). Caveat, stated honestly: the Home "Date coming up"
tile wasn't re-observed because the Compose date picker resists uiautomator
(cells expose content-desc only) and a today-dated plan is excluded by design
(scheduledDate > now) — the tile logic is untouched by this batch and was
live-verified in R15. Full unit suite green, assembleDebug clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Caught live on the fixture couple, second rotation. The first rotation worked;
the second looked identical from the rotating device — dialog confirmed, no
error, keyset swapped locally — and permanently locked the partner out of every
message written afterwards: 🔒 "Couldn't unlock on this device", with no way to
recover on his own, because the one signal that tells a partner to adopt a new
key never fired.
The chain: rotateCoupleKey read the couple through the cache-first
getCoupleById, got the stale keyGeneration=0 (the doc was already at 1), and
computed next = 1 — the value already on the document. Firestore doesn't count
an unchanged value as a change: affectedKeys() omitted keyGeneration, so the
monotonic rule never engaged, onCoupleKeyRotated never fired, and the partner's
device kept seeing "up to date". Meanwhile the write DID replace
wrappedCoupleKey with a wrap of the newer keyset and the rotating device DID
commit it locally — new content under a key only one phone has.
Same disease as C-AUTH-001, new organ: a cache-first read driving a one-shot
decision. Fixed at both layers:
- client: getCoupleByIdFromServer (Source.SERVER) backs the rotation decision;
offline throws, nothing is written, the user is told nothing changed —
rotation must fail loudly rather than half-happen.
- rules: a write that CHANGES wrappedCoupleKey must now carry a strictly
increased keyGeneration. The stranding write is unrepresentable server-side,
whatever any future client computes. (The old rule only checked monotonicity
when keyGeneration was itself in affectedKeys — which is exactly the case
that never arises when the bug happens.)
CoupleKeyRotationRepositoryTest pins it: generation comes from the server never
the cache, the published generation strictly advances, server write precedes the
local commit, offline fails without guessing. Mutation-checked by reinstating
the cached read — the two key tests fail, restored, suite green.
The fixture partner is un-stranded by rotating once more with this build: the
server read yields the true generation, the write is a real change, the trigger
fires, and adoption delivers the latest keyset (which retains every older key,
so the stranded-era messages decrypt too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A couple-key compromise currently exposes everything, forever, because the key
never changes. This adds the rotation ceremony: a fresh AES-256-GCM key becomes
the keyset's primary while the old keys stay for reads. The keyset is a Tink
keyring and every enc:v1: blob carries its key-id internally, so all history
keeps decrypting with zero wire-format changes — none of the 25 isCiphertext
rule sites move. Phase 1 protects FUTURE content only (a stolen keyset still
contains the old key); forward secrecy for history is phase 2, which builds on
the keyGeneration plumbing laid here. The Security-screen copy says so plainly.
The ceremony (CoupleRepositoryImpl.rotateCoupleKey): read the couple fresh so
concurrent rotations collide at the rules instead of overwriting each other →
prepareRotation builds the rotated keyset and re-wraps it under the SAME phrase
(fail-closed with a typed error when this device lacks keyset or phrase; nothing
persisted anywhere) → ONE merge write lands the new wrap + a strictly-increasing
keyGeneration atomically, so the partner can never observe a bumped generation
pointing at the old wrap → only then commitRotation stores locally. A failed
server write leaves the device coherent on the old key; a crash after it
self-heals through the same adoption path as the partner.
Adoption (CoupleEncryptionManager.adoptRotationIfNeeded, hooked into Home's
healing block, synchronously before the screen settles — until the rotated
keyset is stored, new content renders locked): couple.keyGeneration ahead of the
local generation → unwrap the published wrap with the locally-stored phrase →
replace the keyset. Replaced only on success, never deleted on failure, so old
content survives anything. No phrase on this device → needsRecovery, and both
recovery flows already deliver the rotated keyset for free (phrase entry unwraps
the current wrap; partner-assist exports the current keyset). Same phrase both
sides is the entire distribution trick — no new ceremony, no partner action.
Server: onCoupleKeyRotated (couples/{id} update, pure isKeyGenerationIncrease
edge guard so streak/rhythm/re-wrap updates never fire it, and a rules-forbidden
downgrade or redelivered stale event never alerts) sends both members the 🔑
security alert through the house pipeline, bypassing quiet hours like the
restore self-alerts. The push is also functional: the partner's closed app can't
read new-key content until it next loads Home — the tap takes them there.
Rules: isUpdatingRecoveryWrap admits keyGeneration, strictly increasing
(monotonic like encryptionVersion), untouched for plain phrase re-wraps.
Tests (real Tink, mocks stop at storage): history readable after rotation + NEW
writes unreadable by the old keyset — mutation-checked by dropping setPrimary,
which kills exactly that test (a rotation that forgets setPrimary passes
everything else while protecting nothing) — same-phrase unwrap reads both eras
(the partner's whole adoption, proven), prepare persists nothing until commit,
fail-closed without phrase/keyset, adoption state machine incl. corrupt-wrap.
Android suite green, assembleDebug clean, functions 105/105, tsc clean.
Deploy (scoped): firebase deploy --only firestore:rules and
--only functions:onCoupleKeyRotated. Live verify follows deploys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The restore self-alerts fan out to every device the recipient owns — including
the new device that is doing the requesting. "Was this you?" sent to the asker
is noise; the copies that matter go to their partner and to any OTHER device the
real owner still holds (the phished-password-without-device-loss case), and
those are untouched. For a legit single-device owner the self-alert becomes a
clean no-op, which was the point.
The requesting device identifies itself: RestoreManager fetches its own FCM
token at request time and the doc carries it as a create-only, optional
requesterFcmToken. Doc-embedded on purpose — MainActivity's token registration
races the restore flow on a fresh device, so cross-referencing fcmTokens
server-side can't reliably name the requester. Strictly best-effort on the
client (runCatching → null → field omitted, never written hollow): a restore
must never block or fail over a notification nicety, pinned by test.
sendPushToUser gains optional excludeTokens (filtered after merge/dedupe;
excluding everything is a clean zero no-op via the existing empty-list guard),
threaded through the shared queueAndPush — the notification_queue record is
still written, so the in-app alert history stays complete — and applied to the
two self-alerts only; the partner "help them restore" push is deliberately
unfiltered. Rules: requesterFcmToken joins the create allowlist as an optional
plain string (opaque device identifier, no format to pin); partner-update and
status-flip rules are unaffected since the field is create-only. Old clients
never send it; the server reads it only if present — no deploy-order coupling.
Tests: 3 new push.test.ts cases (exclusion, exclude-all no-op, absent-list
unchanged) — mutation check on the filter kills exactly those; 2 new
RestoreManagerTest cases (token embedded; FCM failure never blocks the request).
Functions 101/101, tsc clean; Android suite + assembleDebug green.
Deploy (scoped): firebase deploy --only firestore:rules, then
--only functions:onRestoreRequested,functions:onRestoreFulfilled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aggregateOutcomeStats (scheduled) rolls up per-couple check-in deltas
(couples/{id}/outcomes/day_30|60|90) into the "X% feel closer in N weeks" stat
WITHOUT reading any E2EE content — only the self-reported deltas.
Privacy: counts/percentages only (no couple id or individual scores);
minimum-cohort suppression (N<50 → window omitted, no percentages); EXPORT-ONLY
via the top-level aggregate_stats collection, now explicitly deny-all in
firestore.rules (owner reads via console).
Pure aggregate()/extractCoupleOutcome() unit-tested incl. below-threshold
suppression + a no-PII assertion (6 tests); rules deny client read/write of
aggregate_stats (2 tests). Full functions 53/53, rules 121/121.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New Cloud Function: onEntitlementChanged (Firestore onWrite on entitlements/premium) — edge-triggered inactive→active, notifies the OTHER partner so couple-shared unlock isn't silent
- New notification type SUBSCRIPTION_CHANGED → routes to SUBSCRIPTION
- AnswerRevealViewModel: re-issue markRevealed if best-effort failed (offline/transient) so partner_opened_answer push eventually fires
- firestore.rules: harden users/{uid} update allowlist (defense-in-depth; no live hole)
- 18 new brand glyph vector drawables (drawable-nodpi/)
- SettingsScreen / PlayHubScreen / WaitingForPartnerScreen: swap Material icons for new brand glyphs
- ClaudeQA docs + Future.md updated
Simultaneous game start by both partners created two divergent active sessions (TOCTOU: a
non-transactional check-then-create in GameSessionManager.startGameWithCouple). Each partner
ended up in a separate session with different questions → no shared reveal.
Fix: QuestionSessionRepository.startSessionAtomically runs a Firestore transaction on a
per-couple pointer doc (couples/{cid}/sessions/_active). It reads the pointer (+ the pointed
session) and either returns AlreadyActive (caller joins the existing session) or atomically
creates the new session and re-points the lock. Concurrent starts contend on the one pointer,
so the loser's transaction retries, sees the now-set pointer, and joins instead of duplicating.
The pointer self-heals (checks the pointed session's status) so no clear-on-finish is needed,
and it carries no status/completedAt so it's invisible to the active/history queries.
GameSessionManager routes all 7 games through it. firestore.rules adds member-write for
sessions/_active (deployed).
Verified live on both emulators: atomic create → 1 session + pointer; sequential 2nd start →
joins (1 session); literal parallel-tap race → 1 session (was 2); 0 FATAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sessions allow-update rule required affectedKeys().hasOnly(['status','completedAt']),
but the async-game completion path (markUserComplete) always writes completedByUsers, so
every 'I reached results' write was denied and the session stayed active forever -> the
couple was locked out of starting any new game (only the destructive 'End their game'
worked, since abandonSession only diffs status/completedAt). Rule now permits
['status','completedAt','completedByUsers'], lets any couple member record completion
progress, keeps startedByUserId immutable and status monotonic (active->completed).
Deployed + verified live: both finish a game -> session auto-completes (completedByUsers
=[both]) -> next game starts immediately (no 'Waiting for partner' block).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
couples/{id}/capsules and /challenges had NO rules -> default-deny -> Memory Lane hung on
loader, Connection Challenges couldn't load (live PERMISSION_DENIED). Added member-read +
ciphertext-enforcing capsules rule (title/content/promptUsed = enc:v1:) and a challenges
rule (catalog-referenced progress). Deployed + verified live: both features load, 0 perm
errors. Found during Round-2 re-verify of A-001 (Memory Lane couple-shared also confirmed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CouplePremiumChecker ORs self.isPremium with a live read of the partner's entitlement
doc (reactive). Composer photo/camera/voice buttons + keyboard GIF/sticker insert + the
reaction action gate on canSendMedia: locked buttons show a lock badge and route to the
existing PaywallScreen (with a chat_media paywall analytics event). Text/viewing/receiving
stay free. Rules: paired partner may read the entitlement doc. Verification pending deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Debounced typing flag (typing:{uid:ts}) on the conversation doc, cleared on stop/send/
leave; partner sees 'typing…' with a ~6s TTL safety net (ticker-driven auto-hide). Rules
allow members to write the typing field. Live verification pending the Phase B deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long-press a message for a reaction bar (heart/laugh/thumb/wow/sad/fire), Copy (text),
and Delete (author). Reactions stored as a reactions:{uid:emoji} map; delete sets a
'deleted' tombstone ('This message was deleted') and updates the inbox preview if it was
last. Rules: any member may change only reactions; author may set only deleted. Live
verification pending the Phase B rules deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- NotificationRateLimiter: 20 partner/day, 100/week (was 2/4 — too tight for game activity)
- firestore.rules: messages create allows type=image with mediaUrl or type=text with ciphertext
- storage.rules: chat_media path with 15MB cap
- .gitignore: ClaudeReport.md, docs/img
- Firestore rules: partner can read user doc (name/photo), sender can read own release key
- QuestionThread: status stored UPPERCASE to match rules (lowercase broke discussion)
- GameSessionManager: propagate auto-generated session id (empty id crashed game start)
- AnswerReveal: decrypt partner's selectedOptionTexts from option IDs (showed raw ids)
- FirestoreAnswerDataSource: tolerate Timestamp/Date in updatedAt (serverTimestamp crash)
- FirestoreReleaseKeyDataSource: tolerate PERMISSION_DENIED on existence check (sender can't read)
- QuestionThreadRepository: runCatching status update (legacy lowercase status blocked submit)
- PartnerNotificationManager: suppress notification for active thread, deep link to thread
- ActiveThreadMonitor: new class tracks which thread user is reading (suppresses own notifs)
- DesireSync/HowWell/ThisOrThat: re-open guard skips INTRO if already answered; blank sessionId guard
- AppNavigation: deep link pattern for chat notification
- Replace smart quotes, em dash, prime, right arrow in comments with ASCII equivalents
- Affected: CoupleEncryptionManager.kt, FieldEncryptor.kt, RecoveryKeyManager.kt