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>
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>