From 485663809706cb4c5bb4765fd8a7dddae54f892b Mon Sep 17 00:00:00 2001 From: null Date: Thu, 16 Jul 2026 00:31:45 -0500 Subject: [PATCH] =?UTF-8?q?docs(manual):=20correct=20C-ROTATE-001's=20caus?= =?UTF-8?q?e=20=E2=80=94=20the=20"no=20trigger=20fired"=20evidence=20was?= =?UTF-8?q?=20a=20log-window=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A day later, the same functions:log query showed generation=2 and =3 HAD fired at the exact times of the "failed" rotations. The stranding was real (the partner sat on 🔒 across cold restarts), but the client-side mechanism I wrote down as measured was inferred from log silence — and the CLI's log window is paginated and lagging, so silence proves nothing. The entry now says what was observed, what the logs later showed, what remains unpinned, and why the fixes (server-authoritative read; rules interlock) are correct hardening regardless. New standing lesson recorded: never conclude "X didn't fire" from functions:log absence; prove absence with a side effect. --- docs/Engineering_Reference_Manual.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.