Uh oh!
There was an error while loading. Please reload this page.
fix: stop MemberInfo/message verify from permanently forking rooms - #672
fix: stop MemberInfo/message verify from permanently forking rooms#672sanity wants to merge 3 commits into
Conversation
) MemberInfoV1::verify and MessagesV1::verify hard-rejected the whole state when an entry's member was absent from the local `members` set ("MemberInfo exists for non-existent member" / "Message author not found"). apply_delta + post_apply_cleanup already tolerate this case (they unconditionally sweep such orphaned entries on the next applied delta), so the reject-in-verify path was strictly more conservative than necessary and made a two-fork divergence permanent: once each fork carried an entry the other didn't have a member for, every full-state PUT/resync between them was rejected outright and the forks could never reconcile — matching the production forensics in the issue (freenet-core#4861/#4864). Mirrors the existing BansV1::verify_excluding_cap precedent for an identical "banner not in current members" case: skip re-verifying the signature (we can no longer recover the absent member's key to check it, and the entry carries no authority for a non-current member), rather than rejecting the entire state. This is a room-contract + chat-delegate WASM change (behavior, not wire format), so it re-keys both — migration entries added to legacy_delegates.toml and common/legacy_room_contracts.toml, WASMs resynced, and pointer-records.toml re-signed against the new WASM. Closes#423
sanity
commented
Sep 6, 2026
Drive-by from the merge-law conformance work (#671) — one factual correction plus an argument I think strengthens this PR. Not a review, and I have no objection to the change. Correction: the reasoning behind this PR included that Where that conclusion still holds: steps 3 and 4 prune The argument I would put in the PR description instead, because it does not depend on identifying the orphan's origin at all:
Framed that way this is not tolerating a bug, it is Mirroring [AI-assisted - Claude] |
CI on #672 was red on two checks with one root cause: the change re-keys the room contract, and a re-key has two pieces of follow-through that were not done. **The migration pin.** `registry_values_and_order_are_stable_across_codegen_changes` asserted 31 entries against a registry that now holds 32, because this branch correctly added a `legacy_room_contracts.toml` entry for the pre-fork-fix generation. That test is a deliberate tripwire and its own doc comment says the constant SHOULD change when a genuinely new generation is registered -- so it fired exactly as designed. Updated to 32 with the new blake3 prefix `f6e6f99520959a6d`, and the reason for V32 recorded next to V31's, so the next reader can tell which change re-keyed and why. **The riverctl version.** `check-wasm-sync` refuses a room-contract WASM change while `cli/Cargo.toml` still matches the published crates.io version, because riverctl embeds the WASM to compute the contract key: publish the UI with new WASM and leave riverctl at 0.2.14 and the two target different contracts. That is the February 2026 incident the check names. Bumped to 0.2.15. Neither is a defect in the substance of the fix -- the registry entry, both rebuilt WASM copies and the pointer records were all correct. The re-key ceremony was simply incomplete, which is what these two checks exist to catch. `cargo test -p river-core --lib --features ecies-randomized,migration,mentions`: 254 passed, 0 failed. Claude-Session: https://claude.ai/code/session_018zkyrimPu648DUNG7jAXBw
…ntry The second CI failure on #672, and the tests were right to fire. `chat_delegate.wasm` changed on a branch whose subject is room-contract verification, and the guard's message is explicit that this branch "must not alter the delegate WASM" unless the change is intentional and accompanied by a migration entry. Checked rather than assumed, because a delegate re-key orphans every user's delegate-stored data -- room keys included -- if the migration is wrong: - The change is a NECESSARY consequence, not an incidental rebuild. The delegate compiles against `river_core`, and this branch changes `common/src/room_state/member_info.rs` and `message.rs`. `.claude/rules/delegate-migration.md` globs `common/**` for exactly this. - The migration entry exists and is correct. `legacy_delegates.toml` gained a V31 entry recording `code_hash = a44c640...`, which is the OLD delegate hash -- the generation being migrated FROM, which is what makes existing data findable. That is what `add-migration` is for and the agent ran it in the right order. - `cargo test -p river-core --test migration_test`: 5 passed. `cargo make check-migration`: committed WASM consistent at HEAD. So the pins are updated ALONGSIDE that entry, which is what the guard at chat_delegate.rs:4770 instructs and what its "never alone" warning is about: - `chat_delegate_wasm_is_byte_identical` -> 8a39ab73...ca78d - `legacy_set_fingerprint_is_stable_across_codegen_changes` -> c32c326c1328de9a Both values are CI's own computed results rather than anything derived here. They are not verified locally: `cargo test -p river-ui --bins` cannot compile in this checkout because `asset!("/assets/styles.css")` needs the UI asset pipeline, which is a build-environment gap and not a property of the change. CI is the authority for these two. Claude-Session: https://claude.ai/code/session_018zkyrimPu648DUNG7jAXBw
sanity
commented
Sep 6, 2026
Converted to draft — the tolerant branch admits unsigned |
sanity
commented
Sep 6, 2026
One more instance of the same class, not covered here:
let sender_vk = resolve_member_vk(msg.message.sender, owner_id, parameters,&members_by_id).ok_or_else(|| format!("DM sender {:?} is not a current member", msg.message.sender))?;ifresolve_member_vk(msg.message.recipient, owner_id, parameters,&members_by_id).is_none(){returnErr(format!("DM recipient {:?} is not a current member", msg.message.recipient));}One unresolvable endpoint rejects the entire state, exactly as Note bans are correctly not enforced here — the module comment says so deliberately, so Why it is reachable rather than theoretical: a full-state PUT bypasses It is also directly on the path of the #671 fix, which will re-key the contract and therefore migrate every room's state through a PUT. Your call whether to fold it into this PR (it is the same three-line shape and would share the re-key you are already paying for) or take it separately. Flagging rather than assuming — I have not touched your branch. [AI-assisted - Claude] |
sanity
commented
Sep 6, 2026
🔴 Merge-blocking: this PR would publish riverctl 0.2.15 against a stale, immutable river-coreFound while reviewing #673, which has the identical defect. Flagging here because whichever merges first ships it.
river-core = { version = "=0.1.20", path = "../common", features = [… "migration"…] }and workspace river-core 0.1.20 is already on crates.io (2026-08-23) and immutable. A reviewer downloaded and inspected the published crate:
It is silent by construction. The fixBump all three together, as the V31 precedent commit
It has to be in the same commit as the artifacts. A river-core version bump re-keys the delegate on its own, so doing it afterwards invalidates Why merge-blocking rather than publish-blockingcrates.io is the one irreversible step here. A wrong riverctl 0.2.15 can be yanked but not replaced, and yanking does nothing for anyone who already installed. Everything else in this change is recoverable via the append-only registry. Also relevant to both PRs
Nothing here is a criticism of the change itself — the migration payload is otherwise correct and was verified against the live network's signed pointer record. [AI-assisted - Claude] |
sanity
commented
Sep 6, 2026
🔴 New blocker: riverctl 0.2.15 is already published — |
Problem
Production forensics (freenet-core#4861) found room contract instances stuck in a permanent two-fork oscillation: divergent states that reject each other's deltas/full-states forever. The confirmed error is
MemberInfo exists for non-existent member: MemberId(...).MemberInfoV1::verify(and, same bug class,MessagesV1::verify) hard-rejects the entire state whenever it carries an entry (aMemberInforecord, or a message) whose author/member is absent from the localmembersset. Butapply_delta's unconditional retain-sweep andpost_apply_cleanupstep 4/4b already tolerate and prune exactly this case on every normal delta application — so theverify()path was strictly more conservative than the rest of the contract's own convergence logic, and that asymmetry is what makes a fork permanent: once fork A prunes a member that fork B still carries (with aMemberInforecord or message), every full-state PUT/resync between A and B is rejected outright byverify(), and the two forks can never reconcile.Approach
Mirror the existing precedent for the identical situation in
BansV1::verify_excluding_cap("banning member not in current members list"): skip re-verifying the signature for an entry whose member isn't currently known (we can't recover their key to check it anyway, and the signature was already checked when the entry was created), instead of rejecting the whole state. The entry carries no authority for a non-current member (deputies_of, ban enforcement, and nickname rendering are only ever consulted for ids present inparent_state.members), and it self-heals:apply_delta+post_apply_cleanupprune it on the very next applied delta.Applied the identical fix to
MessagesV1::verify("Message author not found"), since it's the same asymmetry against the samepost_apply_cleanupstep 4b sweep, and is equally low-risk and well-precedented.Left
DirectMessagesV1::verify's analogous "sender/recipient is not a current member" checks alone — DM tombstone/purge semantics make that a more delicate change, and it isn't what's implicated by the reported forensics; flagging it as a plausible follow-up rather than bundling it here.This is a
common/change compiled into both the room-contract and chat-delegate WASM, so it re-keys both:legacy_delegates.toml/common/legacy_room_contracts.toml: migration entries added (old hashes recorded) so existing rooms/secrets survive the re-key.pointer-records.toml: re-signed against the new WASM (third-party integrators resolve this instead of pinning a key).ui/public/contracts/,cli/contracts/: WASMs resynced viacargo make sync-wasm.Testing
member_info.rs/message.rsthat previously assertedverify()rejects an orphaned entry — they now assert it's tolerated.orphaned_member_info_and_message_do_not_reject_full_stateincommon/src/room_state.rs: builds a fullChatRoomStateV1carrying aMemberInforecord and a message for a member absent frommembers, asserts the top-level composedverify()(whatvalidate_stateactually calls) still succeeds, then applies a no-op delta and assertspost_apply_cleanupprunes both orphans and the healed state still verifies — demonstrating the self-healing convergence path this fix unblocks.cargo test -p river-core: all 211+ tests pass (incl.convergence_tests.rs,retention_proptest.rs,summary_determinism_test.rs).cargo make sync-wasm,check-migration,check-room-contract-migration,check-pointer-freshness,cargo test -p river-core --test migration_test --test room_contract_migration_test: all pass.cargo clippy -p river-core --all-targets: no new warnings introduced by this change.cargo check -p river-ui) — this environment'sui/assets/styles.css(gitignored, tailwind-generated) isn't built andtailwindcss/npm deps aren't installed, so that check fails on an unrelated missing asset (asset!("/assets/styles.css")) rather than anything in this diff.chat-delegateandroom-contract, which depend on the samecommoncrate, both build and their WASMs were regenerated successfully.What I'd want a reviewer to check first
MemberInfo/message entry (rather than, say, still validating the signature shape/size while skipping only the "member exists" gate) is the right level of tolerance — I followed theBansV1precedent exactly since it's the same "can't recover the key" situation.DirectMessagesV1::verify's analogous checks should be fixed in a follow-up now that this asymmetry is named, or left as-is given the tombstone/purge complexity.legacy_delegates.toml,common/legacy_room_contracts.toml,pointer-records.toml) — this re-keys both WASMs, so please double check the added registry entries look right before merge.Closes#423
[AI-assisted - Claude]