BLOCKED: fix(ui): revive the interrupted-migration recovery (blocked on #590, #591) - #587
BLOCKED: fix(ui): revive the interrupted-migration recovery (blocked on #590, #591)#587sanity wants to merge 1 commit into
Conversation
af4f0bc to
f2cad5e
Compare
Review round 1 — four independent blind Claude reviewers (code-first, data-loss, skeptical, testing)Full tier: this touches delegate storage and, as originally written, deleted user data. Two blockers landed on the deletion sweep and it has been removed from this PR entirely. The scope is now the durable seal only; the PR body has been rewritten to match. Blocker 1 — the sweep's safety argument was false (code-first, independently confirmed by data-loss)I claimed #527's generation rank made older copies unreachable state. Verified against So deleting older copies is a data decision, not reclamation. The Blocker 2 — "holds K" was inferred from the key list, never from a value read
Disposition: sweep removed. Reclamation needs the airtight rule instead — delete a generation's key only after the merged union is durably written to the current delegate, and only for keys whose values were actually read and parsed. Tracked separately; it is an efficiency fix, not part of the reported bug. Findings fixed in the code that stays
Not adopted
Reviewers were told not to rubber-stamp and each reported explicitly on categories where they found nothing. Both mutation-testing reviewers left the tree clean (verified: [AI-assisted - Claude] |
f2cad5e to
ba4d217
Compare
ba4d217 to
5b0135d
Compare
Review round 2 — findings addressed at
|
| Mutation | Fix |
|---|---|
Seeder gutted to let _ = keys; — the whole PR silently reverted |
seeding decision extracted to the pure seeded_marker_from_keys, plus a pin that the seeder is unconditional and stores the computed value |
Seeding classification inverted (!marker_present_in_keys) — an interrupted migration read as complete, i.e. the #345 data loss |
unit-tested on the real marker key |
Store/Delete direction swapped — mark clears the marker and clear sets it, backwards across sessions |
extracted to the pure marker_write_request, unit-tested both directions |
Reader degraded to let _ = resolved_flag(…) — kept the anchor while ignoring the answer |
pin now requires the whole if let Some(known) = … { return known; } shape, and that the return precedes any localStorage read |
The reviewer was right that the previous pin's own doc claimed a property the code did not have; both that doc and resolved_flag's were corrected.
Also corrected: the module note claimed localStorage behaviour was "exactly as before" for same-origin builds. It is not — once a seed lands, the delegate is authoritative and a marker existing only in localStorage is ignored. That is the intended direction, but it is a change, and the comment now says so.
Not adopted
Source-scan pins prove presence, not execution — wrapping a call in a runtime-false condition still passes. Accepted: grep -rn wasm_bindgen_test ui/ is empty, so for the localStorage branches source scans are the only available net. Deleting a call outright is caught.
Full mutation set, each turning a test red: seeder gutted; seeding inverted; Store/Delete swapped; reader discarding the answer; reader consulting localStorage first; clear not persisting; seed moved after the plan match; import gate reading the durable marker; recovery window never closed; seal made durable again.
828 river-ui tests, full workspace green. A fresh reviewer is reading 5b0135d5 blind.
[AI-assisted - Claude]
a06494b to
1920eae
Compare
Round-2 verification pass — C1 was wrong, now fixed at
|
a6f8765 to
468bd20
Compare
Round-3 review — data-loss lens found a HIGH issue this change newly enabledFixed at HIGH — the recovery could permanently overwrite a room's identitySequence: the marker is PRESENT (the interrupted migration this PR exists to detect). The delegate had B all along — only this client's read failed. I verified the Fix: The other four
Mutation resultsAll five turn a test red: completeness gate always-true; gate consulted but not negated; the mark gate reverted to From the big-picture lensVerified the premise independently from freenet-core source — the missing Their asks are done: One dismissal, put back to the reviewer rather than closed quietly: 838 river-ui tests, full workspace green. Rebased onto [AI-assisted - Claude] |
468bd20 to
35c027b
Compare
BLOCKED — do not merge. Reviving the #345 recovery is not safe as written.The data-loss reviewer answered the question I was blocked on — is "every listed slot parsed" sufficient to exclude the vacant-slot path? — with no, and the reasoning is structural rather than a missed case:
That is correct, and it invalidates my fix as a general solution rather than just finding a gap in it. The worse case: a legacy tombstone deletes a live room — filed as #590Verified in code, not taken on report: No failure is required. A user who left a room under one delegate generation and rejoined it under a later one is enough. #590 is reachable on
Also: #591,
|
35c027b to
e91e07c
Compare
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
…rooms (#593) * fix(ui): rank a legacy generation's absences, not just its presences Two ways the legacy-migration merge loses rooms permanently. Both live on main; both found reviewing #587, which would have widened the first. ## A legacy generation's tombstone deletes a live room (#590) `migrate_legacy_per_room` pushes whatever slot it parses into `slots`, including `RoomSlot::Tombstone`; `reconstruct_rooms` turns those into `removed_rooms`; and `hydrate_loaded_rooms` passes the whole `Rooms` to `merge_from_source`, which unioned every incoming tombstone and then evicted the map against the combined set. So an OLDER generation's tombstone deleted a room the CURRENT delegate held `Present`, and `do_save_rooms_to_delegate`'s tombstone pass CAS-wrote that tombstone over the current slot. `self_sk` cannot be re-derived from the network. No failure is required: leave a room under generation G, take a WASM bump so G becomes legacy, rejoin the room, and any later load whose current-delegate index is empty fires the fan-out and destroys it. ### Root cause, and why the first fix was not enough The merge treated every responding generation as a PEER. `source_rank` existed but was consulted only for `self_sk` conflicts, so nothing constrained what an old snapshot could delete. The first attempt guarded on "is the room in the map right now", which review showed misses the LIKELY interleaving: probes dispatch oldest-generation-first, so the oldest generation usually answers while the map is still empty. Its stale tombstone lands unopposed, the newest generation's `Present` is then skipped by the tombstone check, and the re-save tombstones the room anyway. It also regressed the mirror case: between two legacy generations nothing asked WHICH source had put the room there, so a newer generation's leave was dropped. The actual rule is that a legacy generation's ABSENCES are older evidence exactly as its presences are, and only presences were ranked. Tombstones are now ranked observations too (`MergeRanks` carries both maps): - a tombstone evicts only if it outranks the copy currently held; - a `Present` from a strictly NEWER source clears an older generation's tombstone and restores the room; - an `Authoritative` source — the current delegate, or a deliberate in-session action — outranks every generation, so its removals are unchanged; - a room present with no recorded rank was created or imported in-session and nothing loaded may override it. The justification this replaces — the receiver's tombstone set is authoritative "because legacy delegates predate the tombstone field" (#247) — had outlived its premise: `legacy_delegates.toml` gains an entry on every WASM bump, so recent legacy generations carry per-room tombstones routinely. ## One room's merge failure drops all the rest (#591) Two bare `?` on the fallible per-room `ChatRoomStateV1::merge` returned from the whole function, so every room later in `other.map`'s arbitrary `HashMap` order was never inserted — and a room absent from the map is exactly where the #527 rank check does not run, so the next generation's copy was adopted unranked. Now per-room isolated: failures accumulate, the loop continues, and an aggregate is returned. Because `Err` now means "one or more rooms failed" rather than "nothing merged", `hydrate_loaded_rooms` runs `repopulate_secrets_from_state` and the actions_state rebuild regardless — skipping them left the rooms that DID merge rendering "[Encrypted message - secret vN not available]" until reload. ## Tests Six behavioural tests: a newer generation's `Present` overrides an older generation's tombstone (the probe-order case); an older generation's tombstone cannot evict a newer generation's room; a newer generation's tombstone still removes; a legacy tombstone cannot evict a room the live set holds; it still applies where the live set has none; an authoritative tombstone still removes. #591's test drives REAL merge failures (a configuration signed by a non-owner) and asserts BOTH are reported — deliberately order-independent, since `other.map` is a `HashMap` and any "a later room survived" assertion is a coin flip. An earlier version of that test asserted exactly that and passed under the bug. Mutation-checked, each turning a test red: the eviction rank check disabled; a newer `Present` unable to override an older tombstone; tombstones never recorded; the authority hard-coded at the hydrate call site; the per-room merge aborting. Closes #590 Closes #591 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h * test: cover the #590 session boundary at runtime, not just by pin The drain that carries a ranked resurrection from the merge to the save path had only a source-scan pin, and the pin's message claimed more than its assertion tested: `drain < mark` is textual order only, so calling `mark_room_rejoined` INSIDE the ranks closure satisfied it. That version re-locks a non-reentrant Mutex — on single-threaded WASM it hangs the tab rather than failing a test. - Strengthen the pin: require the collect-out-of-closure shape and place `mark_room_rejoined` after the closure has closed. The deadlock shape now fails it (verified by mutation; it passed before). - Add a runtime test of the boundary itself: merge an older generation's tombstone and a newer generation's Present through the SHARED registry, drain as the call site does, and assert the delegate write overwrites a stored Tombstone with Present. Dropping `ranks.resurrected.insert` turns it red. A second drain must not see the key again, so iterating without draining is caught too. - Repair a doc-comment splice: the #527 wiring-pin block had been severed mid-sentence by a test inserted into the middle of it. Reported by a review lens that re-ran the carry-over end-to-end rather than reading the diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h * test: anchor the #590 drain to the merge that populates it The drain pin asserted the drain's position relative to the MARKING that consumes the resurrection set, never relative to `merge_from_source`, which fills it. Hoisting the drain to the top of the function — next to the `tombstoned` computation, which also takes the ranks lock, so it is a natural place to consolidate the two acquisitions — preserves every shape the pin asserts while draining an EMPTY set. Nothing is marked, `reconcile_room_present` returns to AbortAdoptLeave, and #590 is silently restored. Verified: that mutation compiles and left the suite green before this commit, and fails on the anchor after it. Same failure shape as the two earlier gaps in this PR: the assertion described the code's shape rather than the dependency that makes it work, so a refactor preserving the shape killed the behaviour. Also record, at the drain, that this widens `REJOINED_THIS_SESSION` from "the user deliberately did something" to also mean "the ranks concluded this room should come back" — so a WRONG resurrection is now persisted rather than session-local. Accepted deliberately (a wrongly-resurrected room is one the user leaves again; #590 destroys self_sk, which is unrecoverable), but the blast radius of a bad rank decision grew and the code did not say so. Reported by the data-loss review lens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The #345 interrupted-migration recovery has never run in production. It exists to re-fill rooms stranded by a re-save that was cut short, and it is gated on `is_legacy_migration_in_progress()` — which reads `localStorage`. The gateway serves the app in `sandbox="allow-scripts …"` with no `allow-same-origin`, so the document has an opaque origin and every localStorage access throws. Measured live against try.freenet.org: origin "null"; localStorage -> SecurityError: … lacks the 'allow-same-origin' flag `window.local_storage()` maps that to `Err`, which the reader treated as "flag absent". So the marker was never written, the reader was permanently false, and the recovery was dead code. Nothing surfaces that: a stranded room just looks gone. The marker now lives in the chat delegate's own key/value store — the one durable store the app has in that environment — read back from the current delegate's startup `ListResponse`, which already enumerates its keys, so the cold path costs no extra round trip. A delegate-WASM bump mints an empty store, which is the correct default; the `LEGACY_DELEGATES` fingerprint stays in the key name because `legacy_delegates.toml` can gain a back-filled entry without the WASM changing. ## What this deliberately does NOT do The migration SEAL has the same defect and is NOT moved. Making it durable is unsafe for two independent reasons. The fixed probes and `ListRequest`s in `fire_legacy_migration_request` are raw sends holding no `LoadWorkerGuard`, so `PENDING_LOADS == 0` does not prove the fan-out finished and `schedule_legacy_seal` can fire while a generation is still going to answer. And `freenet_synchronizer.rs` sets `LEGACY_SEAL_PENDING` on ANY "delegate not found" error — which the fan-out provokes within milliseconds on any node missing an old delegate WASM — so a transient all-error fan-out could seal the very node holding the data. Today that seal evaporates and the next session re-probes; a durable one would be permanent, with no unseal path, and it buys little: a user whose re-save succeeded takes the `PerRoom` path next session, which never probes anyway. Tracked as #588. `the_seal_is_not_delegate_persisted` pins the scope. ## Two hazards the durable marker introduces, and how they are closed **It must not latch the identity-import gate.** `rooms_recovery_in_progress()` read the marker directly, which was safe only because the marker was always false in the deployed app — the very defect fixed here. Now that it persists, a migration that can never complete (repeatedly quota-refused saves, or a legacy delegate no longer installed) would leave the key set forever, permanently disabling Import Identity for exactly the cohort whose migration is stuck. A marker from an earlier session means "the room set may be incomplete", which is what `decide_per_room_load_action` needs; it does not mean a recovery is running NOW, which is all the #414 gate cares about. The gate now tracks `RECOVERY_PENDING` — opened before the recovery runs, closed when it returns — plus `PENDING_LOADS`, which covers the workers the recovery spawns. No weaker for the window it guards, and it no longer latches. **A seed must never downgrade a marker written earlier this session.** The delegate write is fire-and-forget, so a reconnect's `ListResponse` can overtake it. With two responding generations, gen 25's success clears the marker, gen 21 re-marks it, and an absent-authoritative seed would store ABSENT — after which `schedule_legacy_seal`'s "never seal over a migration that is mid-flight or has FAILED" guard reads false and seals anyway, the one thing that guard exists to prevent. The write is sent RAW rather than through `send_delegate_request`: nothing consumes the response, and registering a waiter would put the `StoreRequest` and the later `DeleteRequest` under the same single-waiter correlation key, so the Delete would evict the Store's waiter. It also keeps a 10s timeout off the migration path. UI-only: no delegate/contract WASM, Cargo.toml or Cargo.lock change, so no migration entry is required — and deliberately no new delegate generation, which would add another duplicate copy to the quota problem in #586. ## Tests Thirteen tests. Behavioural: the seeding decision on the real marker key (including the no-downgrade rule), which request records which state, marker classification from a key list, fingerprint scoping, load-plan isolation, and the reader's cache resolution. Pins, because the native build has no `window` and cannot execute the localStorage branches at all: the marker is seeded BEFORE the load plan is classified; the seeder is unconditional and stores the computed value; both mutators update the cache and persist in opposite directions; persisting stays fire-and-forget, registers no waiter, and never touches the load state; the import gate tracks the live recovery window rather than the durable marker, and that window is opened and closed around the recovery; and the seal stays session-only. Mutation-checked — each turns a test red: seeder gutted; seeding inverted; Store/Delete swapped; the reader resolving but discarding the answer; the reader consulting localStorage first; `clear` forgetting to persist; the seed moved after the plan match; the import gate reading the durable marker again; the recovery window never closed; the seal made durable again. Refs #586, #588 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHubk7vg1mSjBQaoLVzs2h
e91e07c to
e26a649
Compare
Full-tier review result: DO NOT MERGE — the suite cannot detect whether this change worksRebased onto current main ( The finding that decides itThe marker can be neutered at all 7 mark sites and all 854 tests still pass. Verified directly: inserting The testing lens ran 29 mutations; 10 survived (11 of 36 including the ranking sweep below):
M4 and M5 are BLOCKERs on their own. M5 in particular unpins the gate guarding the path where a vacant room slot adopts a legacy copy with no rank check and the re-save CAS-writes a legacy The pattern is exact: every pure function is tested; every wiring step between them is a token-presence pin. The clear side has no behavioural coverage whatsoever — the only clear-side assertion ( Why this is worse than a normal test gapNo delegate holds the marker key today, so at rollout every user seeds So both verification channels are blind at once: the tests cannot tell us it works, and production will not exercise it for a long time. That is the wrong combination for a path whose failure mode is unrecoverable key loss. Must fix before merge
The strand bug
Same shape one layer down: if B is cut short by the tab closing, no Comments that actively mislead — raised above cosmetic
Also: Newly reachable dead ends — flagged, not fixed hereBoth are UX traps rather than data loss, and fixing them means touching the load-state machine. Proposed as follow-up issues rather than widening this PR:
Also pre-existing and worth its own issue: Corrections made during reviewTwo claims raised mid-review were checked and withdrawn — recorded so they are not carried forward:
What is NOT wrong with this changeThe diagnosis is solid and independently verified: the gateway serves River in a The change earns its place. It is not verifiable yet, and that is what needs doing before it lands. Review lenses: code-first, testing (mutation-driven), adversarial, big-picture, migration-safety. [AI-assisted - Claude] |
Problem
The #345 interrupted-migration recovery has never run in production.
It exists to re-fill rooms stranded by a re-save that was cut short (Nacho's "Freenet Devs" disappeared-after-update), and it is gated on
is_legacy_migration_in_progress(), which readslocalStorage. The gateway serves the app insandbox="allow-scripts allow-forms allow-popups …"with noallow-same-origin— pinned by a test in freenet-core'spath_handlers.rs, so this is a property of the server, not an accident of one deployment. The document therefore has an opaque origin. Measured live against try.freenet.org:window.local_storage()maps that toErr, which the reader silently treated as "flag absent". So the marker was never written, the reader was permanentlyfalse, and the recovery was dead code. Nothing surfaces that — a stranded room just looks gone.It is the only mechanism. On the
PerRoompath no legacy probe fires at all without this recovery, so a room stranded by a partial migration is stranded permanently today.Approach
The marker moves into the chat delegate's own key/value store, read back from the current delegate's existing startup
ListResponse, so the cold path costs no extra round trip. A delegate-WASM bump mints an empty store, which is the correct default; theLEGACY_DELEGATESfingerprint stays in the key name becauselegacy_delegates.tomlcan gain a back-filled entry without the WASM changing. localStorage remains a best-effort mirror, consulted present-wins when the delegate answer is ABSENT — so a same-origin build (dx serve) whose tab closed before the delegate write landed still recovers, which is exactly the case #345 exists for.Three deliberate constraints:
ListRequests infire_legacy_migration_requestare raw sends holding noLoadWorkerGuard, soPENDING_LOADS == 0does not prove the fan-out finished andschedule_legacy_sealcan fire while a generation is still going to answer. Andfreenet_synchronizer.rssetsLEGACY_SEAL_PENDINGon any "delegate not found" error — which the fan-out provokes within milliseconds on any node missing an old delegate WASM — so a transient all-error fan-out could seal the very node holding the data. A durable seal is permanent with no unseal path, and buys little: a user whose re-save succeeded takes thePerRoompath next session and never probes anyway. Tracked as perf(ui): a user with no rooms re-fires all 81 legacy-delegate probes on every page load #588;the_seal_is_not_delegate_persistedpins the scope.ListResponsecan overtake it. With two responding generations: gen 25's success clears the marker, gen 21 re-marks it, and an absent-authoritative seed would store ABSENT — after whichschedule_legacy_seal's "never seal over a migration that is mid-flight or has FAILED" guard reads false and seals anyway.send_delegate_request. Nothing consumes the response, and registering a waiter would put theStoreRequestand the laterDeleteRequestunder the same single-waiter correlation key, so the Delete would evict the Store's waiter. It also keeps a 10s timeout off the migration path.Risks this change carries
Stated explicitly, because reviving a dormant path is not risk-free:
fire_legacy_migration_request's own doc says it "must only be called once the current delegate has confirmed it has no rooms_data" (bug: old delegates overwriting the current active delegate #253), because a legacy response can trigger a save that overwrites newer state. The Multi-tab room loss: chat delegaterooms_datais a blind full-blob overwrite (last-write-wins across tabs) #345 recovery deliberately violates that precondition; its safety rests on the re-save being per-room CAS read-merge-write, so an already-present room merges rather than being clobbered. That argument has never been exercised in production, because the path has never run.DeleteRequestthe UI has ever sent.delegates/chat-delegate/src/handlers.rs::handle_delete_requesthas never executed in production. It is correct on inspection (removes the secret, rewrites the key index) and the marker is not CAS-tracked, soversioning.rs's documented ABA hazard does not apply.backstop_terminalmapsMigrating -> LoadFailed. A recovery that setsMigratingand stalls past the 60s hard max now flips a previously-Loadeduser toLoadFailed. Invisible in the room list (rooms present rendersList) but it disables Import Identity for the session. Previously unreachable.StoreRequestto a delegate whose owner is at 3,998.5 KiB of 4,096 KiB, and the matching Delete never fires because their re-save always fails. Negligible in bytes, but on the wrong side of the quota that is the root cause.What still fixes #586
The banner recurs because a long-standing user's 4 MiB per-user quota on the hosted node is consumed by River's own duplicate room copies across 27 delegate generations, so the migration's re-save is refused and every visit migrates from scratch. Measured: one user pinned at 3,998.5 KiB of 4 MiB, four full copies of the same rooms in generations 21–25, and not one room slot on the current delegate.
That needs (a) reclaiming redundant copies — with a rule that proves redundancy rather than inferring it from generation rank, which is where the first attempt was wrong — and (b) one-off headroom for users already pinned. Design recorded on #586; a follow-up PR.
Testing
834 river-ui unit tests; full workspace green.
Behavioural: the seeding decision on the real marker key including the no-downgrade rule; which request records which state; marker classification from a key list; fingerprint scoping; load-plan isolation; the reader's cache resolution; and the import-gate window mutators.
Pins, because the native build has no
windowand cannot execute the localStorage branches at all: the marker is seeded before the load plan is classified; the seeder is unconditional and stores the computed value; both mutators update the cache and persist in opposite directions; persisting stays fire-and-forget, registers no waiter, and never touches the load state; the import gate tracks the live recovery window rather than the durable marker, and that window closes on quiescence rather than at the call site; and the seal stays session-only.Mutation-checked — each turns a test red: seeder gutted; seeding inverted;
Store/Deleteswapped; the reader discarding the resolved answer; the reader consulting localStorage first; the mirror fallback short-circuiting on ABSENT;clearnot persisting; the seed moved after the plan match; the import gate reading the durable marker; the recovery window never closed; the window mutators swapped; the arming block deleted; and the!dropped from either quiescence guard.That last one also closed a pre-existing hole in
schedule_legacy_seal's pin, where the same inversion means sealing while a generation is still answering — the #527 third cause.Scope
UI-only — no delegate/contract WASM,
Cargo.tomlorCargo.lockchange, so no migration entry is required. Deliberate: minting another generation would add one more duplicate copy to the quota problem..claude/rules/river-publish.mdis updated, since it documented the marker as a localStorage key.Refs #586, #588
[AI-assisted - Claude]