diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index c50ea966..b460f831 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -1492,7 +1492,7 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`. ### 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. +**Cause — with a 2026-07-16 evidence correction.** The original diagnosis: `rotateCoupleKey` read the couple via the cache-first `getCoupleById`, computed next = a value already on the doc, the unchanged field never entered `affectedKeys()`, so no trigger fired and no partner adopted. That mechanism is REAL AS A CLASS (the cache-first read genuinely fed the write; C-AUTH-001 measured the same staleness on the user doc) - but it was INFERRED here from the absence of `generation=2` in `functions:log`, and the Firebase CLI's log window later turned out to have HIDDEN entries: a day later the same query showed `generation=2` and `=3` HAD fired at the time of the "failed" rotations. So the stranding was real and reproducible (the partner sat on 🔒 across cold restarts while the doc was, per those logs, ahead), but the client-side mechanism was not conclusively pinned - the partner's adoption failing/never-running is at least as consistent with the evidence as the unchanged-write theory. Two durable lessons regardless: (1) `firebase functions:log` is a PAGINATED, LAGGING WINDOW - never conclude "X didn't fire" from its silence; prove absence with a side effect (a doc, a push, an on-device state), not a log grep. (2) The fixes stand on their own merits, not on the disputed mechanism: a server-authoritative read for a one-shot generation bump is correct hygiene, and the rules interlock (a changed wrap MUST advance a generation) makes the whole stranding class unrepresentable whatever the client does. **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.