fix(ui): stop a legacy-sourced copy overwriting the stored room identity - #595
fix(ui): stop a legacy-sourced copy overwriting the stored room identity#595sanity wants to merge 4 commits into
Conversation
#588 route 2. `reconcile_room_present`'s diverged-identity arm kept `local` unconditionally and never consulted rank, while the merge path (#527, #590) does. So an identity loaded from an older delegate generation could be CAS-written over the current delegate's slot, destroying `self_sk` — which cannot be re-derived from the network. Reachable today: #592's `get_key_index` turns an unparseable index into an EMPTY one, while `handle_get_request` reads secrets directly and never consults the index. A room therefore goes invisible to `ListRequest` while staying readable, which routes the loader to the legacy probe (its only guard is a localStorage flag that always reads false in the sandboxed iframe) and ends in this overwrite. No code path blocks it; whether the index actually corrupts in the field is unestablished, so this is cheap insurance against an unrecoverable loss rather than a response to an observed incident. ## Why not "keep the higher-ranked identity" There is only ONE rank in existence at that call site. `MergeRanks` is never persisted and `RoomSlot::Present` carries no provenance, so there is nothing to compare the stored slot against. The decidable question is whether the copy WE hold is legacy-sourced; if it is, the stored slot was written by the current delegate, which outranks every legacy generation by construction, so refusing is always right. Refusing costs `room_state` (re-derivable). Writing costs `self_sk` (not). ## The default IS the fix An ABSENT rank entry does not mean "unknown". `record_identity_source` deliberately records nothing for an already-present unranked room, which is exactly how an in-session create/import looks — so absent must read as SOURCE_RANK_AUTHORITATIVE. `unwrap_or(0)` would make every locally created room overwritable by any legacy copy, strictly worse than no guard. Extracted into `identity_rank_for_save` and pinned, because the first mutation run showed `unwrap_or(0)` SURVIVING the behavioural tests: they call `reconcile_room_present` directly and never exercised the caller's default. Mutations verified red: unwrap_or(0); `<=` for `<`; comparison reversed; compared against SOURCE_RANK_AUTHORITATIVE rather than current_delegate_source_rank(); `ranks.tombstone` for `ranks.identity` (compiles — both are HashMap<RoomKey, u32>); guard deleted; helper ignoring the registry. Preserved, each with a test that fails if the guard over-reaches: #414 in-session identities still win; #420 multi-tab last-writer-wins is untouched; a legacy-sourced copy with a MATCHING identity still merges and persists; Absent/Tombstone paths never consult rank. Residual, NOT closed: a slot written by an older River build that itself held a legacy-sourced identity violates the premise, and detecting that needs persisted provenance. This closes route 2; it does not make the branch correct in general. Also drops an unused import introduced by #593. Design and test matrix specified by an independent review lens before implementation. Refs #588, #592 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
The guard's lookup key and the merge path's storage key must agree, and nothing in the suite could catch a divergence: every other test both inserts and reads through the same call, so a changed key derivation would keep them all green while the production lookup missed every time — returning the AUTHORITATIVE default and silently disabling the guard. Traced first (`record_identity_source` keys by `vk.to_bytes()`, same as `identity_rank_for_save`), then driven end to end, because the property is a dependency between two functions rather than a shape. Verified by mutating the reader to key differently: red. Also confirmed while checking, and worth recording: the merge side uses the identical `unwrap_or(SOURCE_RANK_AUTHORITATIVE)` default, so the two sides agree on what an unranked room means; and `current_delegate_source_rank()` is `LEGACY_DELEGATES.len()`, a compile-time constant within a session, while ranks are never persisted — so adding a generation cannot shift the scale under an already-recorded rank. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Found reviewing my own change. Adding the guard made `wrote == false`
AMBIGUOUS: it now means either "adopted a remote Tombstone" (the
delegate holds a Tombstone) or "refused to overwrite a diverged
identity" (the delegate holds a PRESENT slot we declined to touch).
The caller collapsed both to `SavedSlot::Tombstone`.
That is a lie about what the delegate holds, in a cache consulted to
skip redundant writes — the mirror image of the `Present(h)` lie the
existing comment already warns about. On a refusal we know neither the
stored content hash (it is the other identity's) nor that a tombstone is
there, so the only honest record is NONE: forget the room. That costs
one redundant re-read next pass; a wrong entry can skip a genuine write.
`reconcile_room_present` now returns `ReconcileOutcome` rather than
`Option<Vec<u8>>`, so the two aborts are distinguishable by type instead
of by the caller guessing. A `written()` helper keeps the call sites that
only care whether a write happened.
Three mutations verified red: collapsing the refusal branch back into
the tombstone branch; the refusal arm setting its flag false; the guard
returning AdoptedTombstone.
Two defects in the pin itself, both found by mutation rather than by
reading, both of which had left it asserting nothing:
- `find("\n}\n")` does not terminate this function. It ran to EOF and
swept in two later `#[cfg(test)]` modules, so 115,086 chars were
scanned instead of 2,584 and every assertion was satisfied by text
outside the function. Now brace-matched, with a length guard that
fails loudly if the slice is ever the whole file again.
- The pin then matched its OWN source: `include_str!` includes the test
module, this test sits before the function it inspects, so a plain
needle literal found the test's copy first and it brace-matched itself.
The needle is now built with `concat!` so the test's source does not
contain it.
Also: do NOT copy the `split("mod tests {")` idiom from the
response_handler pins into this file — its test module precedes the save
loop, so that yields a prefix without the function at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Code-first review, all findings verified against the current head rather than accepted. F2 (the one that mattered): replacing the call-site `identity_rank_for_save(&vk.to_bytes())` with a constant disables the guard in production and leaves 841/841 green — every behavioural test calls `reconcile_room_present` directly with an explicit rank, and the two registry tests exercise the helper in isolation. Nothing asserted the loop calls it. Same untested-half pattern as the default, one level up: I fixed the default's untested half and created a new one at the seam. Pinned by source (no injectable seam — the loop goes straight to the websocket) and mutation-verified. F3, and this correction is mine to own: "refusing costs `room_state`, which the network can re-derive" understated the cost. Nothing in that `RoomData` is persisted on the refused pass, including `invitation_secrets`, whose loss leaves messages sealed under a dropped version undecryptable for good. And it is not one session: the trigger recurs every load, since an empty index means the current-delegate copy is never fetched, so the session stays diverged rather than converging. The merge side REPAIRS in the same situation (adopts the higher-ranked identity); this only refuses. Still the right trade against destroying `self_sk`, but the comment now says what it actually costs. Converging would make the save path mutate identity, which nothing else does — its own change. F5: three doc comments asserted "the save path is rank-blind", which this PR falsifies. Each is now scoped to TOMBSTONES, which is still true (a `RoomSlot::Tombstone` carries no rank to consult) and is the part the surrounding text is actually about. F1's docs: `reconcile_room_present`'s own doc still described an `Option`-shaped return. Rewritten around the two abort REASONS and why they cannot be collapsed. (F1 itself was fixed in 33cc378.) Renamed `the_save_path_reads_the_rank_the_merge_path_recorded` to `identity_rank_for_save_reads_the_rank_...`: the reader it drives is the helper, not the save loop, and the old name is exactly what a future reader would trust when deciding the wiring was pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Review result: blocking regression found — parking this PRTwo independent blind reviewers read this branch (adversarial lens + code-first; a data-loss lens was attempted repeatedly and never produced a report, so it is not counted as a clearance). One blocking finding, which I then verified against the code myself. The guard's stated premise does not hold during the legacy fan-outThe comment at
That conflates which delegate key the bytes live under with which generation supplied the identity inside them. During migration, Concrete sequence (32 legacy generations, so
Net: memory holds B, the delegate keeps A, and B becomes unreachable for good. On Second finding, same rootThe deferred signing-key migration ( Lower-severity (real, but holes in a new guard rather than regressions)
Note the adversarial reviewer did not run mutation testing (the brief forbade modifying the worktree), so its non-vacuity claims are reasoning rather than measurement. StatusParking this as a draft. It fixes a bug that is real in code but has no established field trigger, while currently introducing one that fires in ordinary migration — net-negative as written, and there is no incident forcing a rushed reshape. The available direction: [AI-assisted - Claude] |
Problem
reconcile_room_present's diverged-identity arm keepslocalunconditionally and never consults rank, while the merge path (#527, #590) does. The save path contains zero rank reads. So an identity loaded from an older delegate generation can be CAS-written over the current delegate's slot, destroyingself_sk— which cannot be re-derived from the network.Reachability (#588 route 2): #592's
get_key_indexturns an unparseable index into an empty one, whilehandle_get_requestandhandle_get_versioned_requestread secrets directly and never consult the index. A room therefore becomes invisible toListRequestwhile remaining fully readable by key — which routes the loader intoProbeLegacy, whose only guard is a localStorage flag that reads false forever in the sandboxed opaque-origin iframe. A legacy generation then answers with the old identity and the re-save overwrites the stored one.No code path blocks that chain, and the terminal step was executed rather than reasoned about (
stored_self_sk_survived=false). Whether the index actually corrupts in the field is unestablished — quota exhaustion was investigated and cleanly ruled out (the quota check precedes the write; the write is tmp+rename atomic). So this is cheap insurance against an unrecoverable loss, not a response to an observed incident.Approach
Why not "keep the higher-ranked identity"
That phrasing does not survive contact with the code, and the reason is #590's session boundary again: there is only ONE rank in existence here.
MergeRanksis never persisted andRoomSlot::Presentcarries no provenance, so there is nothing to compare the stored slot against. Any implementation quietly becomes "compare local's rank to a constant", and the constant is then the entire fix.What is decidable is whether the copy we hold is legacy-sourced. If it is, the stored slot was written by the current delegate, which outranks every legacy generation by construction, so refusing is always right. Refusing costs
room_state, which the network can re-derive. Writing costsself_sk, which nothing can.The default IS the fix
An absent rank entry does not mean "unknown, assume the worst".
record_identity_sourcedeliberately records nothing for a room that is already present and unranked — exactly how an in-session create or import looks — so absent must read asSOURCE_RANK_AUTHORITATIVE.unwrap_or(0)would make every locally created room overwritable by any legacy copy: strictly worse than having no guard.It is extracted into
identity_rank_for_saveand pinned, because the first mutation run showedunwrap_or(0)SURVIVING — the behavioural tests callreconcile_room_presentdirectly with an explicit rank and never exercised the caller's default. Same untested-half pattern that produced four findings in #593.The rank is read once before the per-room loop, not inside the CAS closure: the closure re-runs per retry and this is the same non-reentrant mutex that produced a near-deadlock in #590's drain.
Testing
Four-case matrix driving the real function against real slot bytes:
Ok(None), storedself_skintactThe last case is the one that catches an over-broad guard that would silently stop persisting every migrated room. Plus a test that
Absent/Tombstonepaths never consult rank, so round-9 rejoin behaviour is untouched.Mutations verified red:
unwrap_or(0);<=for<; comparison reversed; compared againstSOURCE_RANK_AUTHORITATIVErather thancurrent_delegate_source_rank();ranks.tombstonesubstituted forranks.identity(compiles — both areHashMap<RoomKey, u32>); guard deleted; helper ignoring the registry entirely.839 tests green, clippy clean. Also drops an unused import introduced by #593.
Residual — NOT closed
The design rests on "the stored slot was written by the current delegate, so it outranks any legacy generation". True for slots written by this client, but a slot written by an older River build that itself held a legacy-sourced identity violates it, and detecting that requires persisted provenance — a much larger change. This closes route 2; it does not make the branch correct in general.
Sequencing
This is a precondition for #588's seal, not a follow-up. Repairing the seal closes route 2 but opens route 1 (an interrupted migration can then set the in-progress flag), and route 1's trigger — a tab closed mid-migration — is far more common than index corruption. Landing the seal first would trade a rare trigger for a common one.
Design and test matrix were specified by an independent review lens before implementation.
Refs #588, #592
[AI-assisted - Claude]