Landmine audit after C-ROTATE-001 (user: "any landmines that need fixing?"),
hunting the class that has now bitten twice — stale reads feeding one-shot
decisions. Two found, both fixed.
1. A WARM app never adopted a partner's rotation at all. Adoption ran at process
start (MainActivity, once per uid) and on Home's initial load — and nowhere
else. The 🔑 push routes to Security, which doesn't adopt; the couple listener
in HomeViewModel watches the USER doc (pairing state), not the couple doc. So
an app sitting open on any screen when the partner rotates renders 🔒 for
every new-key message until process death — and a foregrounded process can
stay warm for days. Fix: the couple doc is now observable
(FirestoreCoupleDataSource.observeCouple → CoupleRepository.observeCoupleForUser,
composed observeUser → coupleId → flatMapLatest so pairing changes reroute
the stream), and CoupleKeyRotationAdopter.watch(uid) adopts on every
emission. MainActivity's hook is now that long-lived watch (collectLatest on
the auth uid: sign-out cancels it cleanly). Snapshot listeners deliver the
partner's write from the server within seconds — no cache trap, no push
required, warm or cold. Adoption stays idempotent, a failed adoption is
recorded and the watch keeps collecting, and a stream error is recorded and
ends the watch quietly (degrades to the one-shot hooks, never crashes).
2. updateStreak was the same disease verbatim: cache-first get() → compute next
streak → merge write. A stale snapshot double-counts or clobbers the streak
when both partners answer near-simultaneously. It now runs as a Firestore
transaction — server-read values, retried on contention, concurrent answers
converge. Same StreakCalculator semantics, same skip-if-unchanged.
Audited and deliberately left alone: updateDisplayName/updateSex read coupleId
via a cache-first snapshot, but a stale/missing coupleId only means the field is
stored plaintext until migrateProfileFields heals it on key-unlock — self-
healing already exists, so no change. requestRestore is stale-cache-immune
(delete-then-set on a fixed doc id). The backup manifest already does CAS.
CoupleKeyRotationAdopterTest pins the watcher contract (adopt per emission, skip
nulls, keep collecting past failures, stream errors recorded not thrown);
mutation-checked — dropping the recording fails exactly those two tests. Full
suite green, assembleDebug clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>