Skip to content

fix(ui): stop a legacy-sourced copy overwriting the stored room identity - #595

Draft
sanity wants to merge 4 commits into
mainfrom
fix/save-path-rank-consult
Draft

fix(ui): stop a legacy-sourced copy overwriting the stored room identity#595
sanity wants to merge 4 commits into
mainfrom
fix/save-path-rank-consult

Conversation

@sanity

@sanity sanity commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

reconcile_room_present's diverged-identity arm keeps local unconditionally 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, destroying self_sk — which cannot be re-derived from the network.

Reachability (#588 route 2): #592's get_key_index turns an unparseable index into an empty one, while handle_get_request and handle_get_versioned_request read secrets directly and never consult the index. A room therefore becomes invisible to ListRequest while remaining fully readable by key — which routes the loader into ProbeLegacy, 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. MergeRanks is never persisted and RoomSlot::Present carries 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 costs self_sk, which nothing can.

The default IS the fix

An absent rank entry does not mean "unknown, assume the worst". record_identity_source deliberately records nothing for a room that is already present and unranked — exactly how an in-session create or 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 having no guard.

It is extracted into identity_rank_for_save and pinned, because the first mutation run showed unwrap_or(0) SURVIVING — the behavioural tests call reconcile_room_present directly 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:

case expected
legacy rank + differing identity Ok(None), stored self_sk intact
current-delegate rank + differing writes local (#420 unchanged)
absent rank + differing writes local (#414 unchanged)
legacy rank + matching identity normal merge, still persists

The last case is the one that catches an over-broad guard that would silently stop persisting every migrated room. Plus a test that Absent/Tombstone paths never consult rank, so round-9 rejoin behaviour is untouched.

Mutations verified red: unwrap_or(0); <= for <; comparison reversed; compared against SOURCE_RANK_AUTHORITATIVE rather than current_delegate_source_rank(); ranks.tombstone substituted for ranks.identity (compiles — both are HashMap<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]

sanity and others added 4 commits August 3, 2026 21:20
#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
@sanity

sanity commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Review result: blocking regression found — parking this PR

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

The comment at ui/src/components/app/chat_delegate.rs:4944 justifies the refusal with:

"If it did, the stored slot was written by the current delegate, which outranks every legacy generation by construction, so refusing is always right."

That conflates which delegate key the bytes live under with which generation supplied the identity inside them. During migration, ui/src/components/app/freenet_api/response_handler.rs:1891 fires a save_rooms_to_delegate() for every responding legacy generation, so the current delegate's slot is routinely written by the legacy migration itself. RoomSlot::Present carries no provenance, so the guard cannot tell the two apart.

Concrete sequence (32 legacy generations, so current_delegate_source_rank() == 32):

  1. Generations are probed together. LEGACY_DELEGATES is oldest-first age order (chat_delegate.rs:747) and the oldest copy usually answers first (:269). Gen 2 answers with room R carrying identity A; the slot is empty, so this is a StoreFresh and the slot becomes Present(A).
  2. Gen 20 answers with identity B. The ranked merge correctly prefers B in memory and records identity[R] = 20.
  3. Gen 20's save runs: identity_rank_for_save(R) == 20 < 32 and local.self_sk (B) != remote.self_sk (A)RefusedIdentityOverwrite. Nothing is written.
  4. request_legacy_seal_on_quiescence() fires and no later session re-probes gen 20.

Net: memory holds B, the delegate keeps A, and B becomes unreachable for good.

On origin/main step 3 writes B and the rollback does not occur, so this is a regression in the migration path — the scenario this workstream exists to fix. It is also the same hazard response_handler.rs:1913-1921 already describes in its own words as what the #527 seal-delay was written to prevent, reintroduced from the save side.

Second finding, same root

The deferred signing-key migration (response_handler.rs:1640-1710) is unconditional and knows nothing about the refusal. So a refused pass can leave room:<vk> → Present(self_sk = A) while __signing_key:<room> → B — the delegate signing with an identity the stored room does not carry, which is the split #527 fixed, re-created from the other direction. The refusal cannot be evaluated in isolation from the rest of the migration.

Lower-severity (real, but holes in a new guard rather than regressions)

  • TOCTOUrooms is snapshotted once at chat_delegate.rs:5079 but identity_rank is read per-room later at :5149, after awaited round-trips during which deferred merges can mutate both. The guard can then pass on a rank that does not describe the snapshot it is about to write.
  • Source pin satisfiable by an unrelated matchchat_delegate.rs:1765 searches the whole extracted body for SavedSlot::Tombstone, which also occurs in the tombstone loop (:5225, :5231), so deleting the bookkeeping the assertion protects leaves the pin green.
  • chat_delegate.rs:1739 — measured body length 9,215 against a 10,000-char guard; the next ~15 lines trip it, with a message that misdirects.

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.

Status

Parking 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: ROOM_SLOT_STATE already records which rooms this session wrote, which is exactly the missing provenance — if this session wrote the slot, overwriting it is safe. That is a reshape of the guard rather than a tweak, and would need a fresh review round.

[AI-assisted - Claude]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant