docs(manual): C-ROTATE-001 landmine — cache-fed keyGeneration stranded the partner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cabfca5e28
commit
055ea4fbf3
|
|
@ -1478,6 +1478,12 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
|
||||||
**Fix (R15)**: `scripts/theme-scan.sh` is a mandatory Pass C pre-check that statically detects hardcoded colors, direct `painterResource`/`Image` usage outside `BrandIllustration`, mismatched container/surface colors, and missing `preview`s. Run it before any manual visual sweep; file findings in `ClaudeReport.md` and counts in `ClaudeQACoverage.md`.
|
**Fix (R15)**: `scripts/theme-scan.sh` is a mandatory Pass C pre-check that statically detects hardcoded colors, direct `painterResource`/`Image` usage outside `BrandIllustration`, mismatched container/surface colors, and missing `preview`s. Run it before any manual visual sweep; file findings in `ClaudeReport.md` and counts in `ClaudeQACoverage.md`.
|
||||||
**Re-introduction risk / still-open**: any new art rendered via a **direct** `painterResource(R.drawable.illustration_*)` (NOT `BrandIllustration`) bypasses theme-correct loading and feathering. Always route opaque rounded-rect illustrations through `BrandIllustration`; keep `MaterialTheme.colorScheme` for surfaces; add Tier 1/2 previews for new screens. A hardcoded `CloserPalette.*` dark value or a raw `Color.White`/`Color.Black` will be flagged by `theme-scan.sh`.
|
**Re-introduction risk / still-open**: any new art rendered via a **direct** `painterResource(R.drawable.illustration_*)` (NOT `BrandIllustration`) bypasses theme-correct loading and feathering. Always route opaque rounded-rect illustrations through `BrandIllustration`; keep `MaterialTheme.colorScheme` for surfaces; add Tier 1/2 previews for new screens. A hardcoded `CloserPalette.*` dark value or a raw `Color.White`/`Color.Black` will be flagged by `theme-scan.sh`.
|
||||||
|
|
||||||
|
### C-ROTATE-001 - a cache-fed keyGeneration made rotation strand the partner
|
||||||
|
**Symptom**: the SECOND couple-key rotation on a device looked exactly like success (dialog confirmed, no error, keyset swapped locally) but the partner never adopted - every message the rotating device wrote afterwards rendered as 🔒 `Couldn't unlock on this device` on the partner's phone, permanently, with no self-recovery path. Reproduced live on the fixture couple.
|
||||||
|
**Cause**: `rotateCoupleKey` read the couple via the cache-first `getCoupleById`, got a stale `keyGeneration` (0 when the doc was at 1), and computed next = 1 - the value already on the document. Firestore does not count an unchanged value as a change: `affectedKeys()` omitted `keyGeneration`, so the monotonic rules clause never engaged, `onCoupleKeyRotated` never fired, and partners comparing generations saw "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 holds.
|
||||||
|
**Fix (`cabfca5e`)**: (1) client - the rotation decision reads via `getCoupleByIdFromServer` (`Source.SERVER`); offline throws, nothing is written ("fail loudly rather than half-happen"). (2) rules - a write that CHANGES `wrappedCoupleKey` must carry a strictly-increased `keyGeneration`, making the stranding write unrepresentable server-side whatever a future client computes. Pinned by `CoupleKeyRotationRepositoryTest` (mutation-checked: reinstating the cached read fails 2 of 4).
|
||||||
|
**Re-introduction risk**: same disease as C-AUTH-001, different organ - **any cache-first Firestore read feeding a one-shot, state-advancing decision** (a counter bump, a generation, a "does X exist yet"). `Source.DEFAULT` is for rendering; decisions that write must read the server or be structured so a stale read is harmless. Note the rules subtlety that hid this: a monotonicity check guarded on `affectedKeys().hasAny([field])` never fires for the *unchanged-value* write - guard on the thing that changed (`wrappedCoupleKey`), not the thing you hope changed.
|
||||||
|
|
||||||
### C-AUTH-001 - a `users/{uid}` read right after sign-in returns a field-less document
|
### C-AUTH-001 - a `users/{uid}` read right after sign-in returns a field-less document
|
||||||
**Symptom**: signing in on a *new device* with a fully set-up, paired account routed to `CREATE_PROFILE` ("What should your partner call you?", over a `🔒 Couldn't unlock on this device` value) instead of `RECOVERY`. Reproduced end to end on a throwaway emulator with a real paired account.
|
**Symptom**: signing in on a *new device* with a fully set-up, paired account routed to `CREATE_PROFILE` ("What should your partner call you?", over a `🔒 Couldn't unlock on this device` value) instead of `RECOVERY`. Reproduced end to end on a throwaway emulator with a real paired account.
|
||||||
**Cause**: for roughly the first 200ms after sign-in, `getUser(uid)` returns a document that **exists with no fields at all** - no `email`, no `displayName`, no `coupleId` - and the real document only lands afterwards. Sign-in registers the FCM token, and that `SetOptions.merge()` write materialises `users/{uid}` before the profile has synced. Measured on-device: two reads at t+0ms field-less, the real document at t+215ms. `Source.SERVER` does **not** dodge it, so "read from the server" is not the fix.
|
**Cause**: for roughly the first 200ms after sign-in, `getUser(uid)` returns a document that **exists with no fields at all** - no `email`, no `displayName`, no `coupleId` - and the real document only lands afterwards. Sign-in registers the FCM token, and that `SetOptions.merge()` write materialises `users/{uid}` before the profile has synced. Measured on-device: two reads at t+0ms field-less, the real document at t+215ms. `Source.SERVER` does **not** dodge it, so "read from the server" is not the fix.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue