Repository navigation
fix(room): merge-law conformance for the room contract (#703) - #793
Merged
Merged
Conversation
…tale-delta modes (#703) Extends the randomized fork-merge closure test with the three shapes #703 describes, before any fix, so each fix lands against a failing test: - ending() now records whether the FIRST exchange is commutative (merge(A,B) == merge(B,A)), the state_commutativity law freenet-core's verifier enforces, and every mode asserts it. - Mode::Collide: fork B's owner bumps the configuration version from the same base, so the forks hold different configurations at an equal version (defect 1). - stale_summary_deltas_apply: a delta computed against the receiver's fork-base summary must apply, verify and leave cleanup at its fixpoint (defect 2, delta_idempotence). - A fourth shape starting at max_recent_messages = 1, so configuration changes raise the cap (defect 3). All five fork tests fail on this commit; split_delivery_converges passes. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
ChatRoomStateV1 now has an inherent `merge` with the trait's signature, so every `room.merge(..)` call (contract update_state, UI GET/refresh/ backward-probe/delegate merges, riverctl) resolves to it instead of the scaffold default. Only an explicit ComposableState::merge reaches the default, and the pin tests fail if the inherent method goes away. The default summarized the receiver with its retention horizons and applied the sender's delta against them. In a one-shot merge that is wrong twice: the horizon reads the receiver's pre-merge cap (defect 3, a raised max_recent_messages), and it assumes everything the receiver holds survives, which a ban or prune in the merge breaks. Either way merge(A,B) kept fewer messages than merge(B,A). Fixing only the cap (the issue's proposal) left 11 of 120 honest pairs non-commutative at 30 seeds, all of the second kind. The merge now produces what applying everything the receiver lacks gives (horizons open), so the result is a function of the union. It first merges with horizons read against the MERGED configuration, and repeats with them open only when a capped set that withheld something ended below its cap; a set at its cap provably prunes everything withheld. That keeps the common case as cheap as before and keeps lowering_max_recent_messages_from_above_the_work_bound_converges green. The fork test now checks every merge against the open-horizon merge. The DM per-pair and global horizons go through the same path. The UI's merge_incoming_state keeps its horizons (it runs per network event) but reads them against the merged configuration, and outbound_summary now uses the shared ChatRoomStateV1Summary::without_retention_horizons. Fork test at FORK_SEEDS=30: non-commutative first exchanges drop from 29 to 4 (honest), 45 to 7 (adversarial), 27 to 11 (extended); every one left is a rejected merge (defect 2) or, in collide mode, the configuration tie (defect 1). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…#703 defect 1) Two owner devices can sign different configurations at the same configuration_version. The summary carried only the version, so neither peer saw the other's, delta sent nothing, and the room stayed forked (409 silent divergences and 53 permanent rejections in 4000 collide-mode pairs at FORK_SEEDS=1000 before this change). The configuration now ranks like member_info: (version, SigDigest), the summary is that rank, delta offers an outranking configuration, and apply_delta adopts one. member_info's `outranks` (strict >, ties keep the incumbent) is now the single generic tie rule for all four users. apply_delta also stops rejecting: a configuration that does not outrank the held one (a re-delivery or a stale delta) is a no-op, and an owner-signed one that fails a value check (zero cap, changed owner_member_id, public display metadata in a private room) is skipped. An Err for a validly signed record fails every whole-state merge with a peer holding it. A bad signature still rejects. The checks moved into AuthorizedConfigurationV1::check_values so tests can pin the reason. Siblings with the same shape: - upgrade pointer: summary (version, SigDigest); apply_delta now ignores a pointer that does not outrank the held one (it used to overwrite unconditionally, so a stale delta rolled the pointer back). - DM purge envelopes: summary carries (version, SigDigest); at an equal version the greater digest wins instead of first-seen. Summary types changed, so river-core goes to 0.3.0 (riverctl's pin follows). Tests that asserted the old Err now assert the skip and the specific reason via check_values. Fork test at FORK_SEEDS=300: collide mode has 0 permanent rejections, 0 silent divergences, and every remaining non-commutative first exchange is a rejected merge (defect 2). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
freenet-core sends a delta computed against its cached belief of the receiver's summary, which can predate the receiver's own changes. Such a delta re-delivers records the receiver holds, or omits an inviter it has since pruned, and the room contract rejected the whole delta for it: a full-state resync each time, and a delta_idempotence violation for the merge-law verifier. On main, 4633 of 31975 stale deltas in the fork test (FORK_SEEDS=1000) were rejected. No validly signed record now fails a delta merely for being held, stale or unplaceable: - bans: a ban whose id is held is skipped (the summary and delta compare ids); the flood bound counts only new bans, against max(max_user_bans, MIN_BANS_DELTA_BOUND = 100), so a peer that already applied a lowered cap no longer refuses a lagging peer's delta. - ban evidence: the same floor, counting only records not already held. - members: held ids are skipped before any check and do not count toward the work bound; a member whose inviter is neither held nor in the delta is skipped with everyone invited through them, and re-sent later with the inviter. Bad signatures, cycles and self-invitations still reject. - secrets: an older current_version is a no-op; a held encrypted blob is a no-op; at a held version the record with the greater signature digest wins (the summary now carries the digest). The UI and delegate rotation paths sign different records for one version (created_at differs), so concurrent rotation used to fail with "Duplicate secret version". A DIFFERENT blob at a held (version, member) is still rejected: it can only carry a different secret and has no consistent tie-break. Scoped out, documented at the site; it stays "inconclusive" to the verifier. (Configuration re-delivery became a no-op in the previous commit.) Tests that asserted the old errors now assert the no-op or skip; new tests pin the ban floor, held-record counting for evidence, and the missing-inviter skip. The honest fork test now asserts zero rejected merges. Fork test at FORK_SEEDS=300: all six tests pass, including one-shot commutativity in every mode and stale_summary_deltas_apply. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
a_failed_rekey_merge_changes_nothing_and_stays_owed built its failing merge from a private configuration with a public display name. Since the configuration fix that configuration is skipped (owner-signed but invalid) rather than rejected, so the merge succeeded and the fixture's premise failed. Seal the display name so the configuration applies and the public message is what the merge rejects, as the comment describes. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
`cargo clippy -p river-core --all-targets -- -D warnings` failed on main (constant assertions, a Default-then-assign, identical if branches, a repeat(1)), as did two UI warnings (an unused test import, an unneeded mut). All are test-only or no-op changes; the merged if branches keep the RNG draw order, so seeded generators are unchanged. Not fixed here: riverctl's test targets still fail clippy (deprecated ProbeDriver::on_timeout in three tests, which need a semantic choice between on_absent and on_unknown, plus four style lints), and the UI's native/wasm builds report dead code that is used only on the other target. CI does not run clippy. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…703) Registers the outgoing generations before shipping the #703 WASM: - room contract V35, eb2222c3 (common/legacy_room_contracts.toml) - chat delegate V35, 49a3122b, delegate key 10792322 (legacy_delegates.toml; the delegate links river-core's room state) Then rebuilds both with scripts/sync-wasm.sh and copies them to ui/public/contracts and cli/contracts: - room_contract.wasm eb2222c3 -> de327f61 - chat_delegate.wasm 49a3122b -> d88db240 riverctl embeds the room contract, so it goes to 0.2.22 (CHANGELOG entry, Unreleased) against river-core 0.3.0. verify() is unchanged in every field (bodies identical to main), so the permissionless migration PUT accepts every state the old contract held; only summaries, deltas and apply_delta changed. Pointer records are NOT re-signed in this commit (check-pointer-freshness will fail): signing bumps each record's version, and must happen once, against the final WASM, after review. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
chat_delegate_wasm_is_byte_identical pins the committed delegate; the #703 rebuild moved it 49a3122b -> d88db240, with 49a3122b registered as V35 in legacy_delegates.toml in the previous commit. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
A recipient's purge envelopes are now ordered by PurgeRank (version, then signature digest) and nothing else: the higher-ranked envelope replaces the held one whatever either lists. apply_delta also used to require a higher version's list to contain the held one's, which made the choice depend on which envelope a peer saw first, so peers that saw them in a different order did not converge and whole-state merges were not commutative. A total order on the envelopes is what makes the outcome order-independent. Clients still carry tombstones forward (advance_recipient_purges unions the previous list); the contract no longer enforces it, so an envelope that omits a token lifts that tombstone. Docs that stated the old rule are updated. Tests: higher_ranked_purge_envelope_wins_whatever_it_lists replaces the old superset test, and purge_envelope_merges_are_commutative_and_converge merges every pair of peers holding one of seven envelopes (equal and different versions, any list relation) and asserts one-shot commutativity and convergence on the highest rank. Both fail with main's rules restored in apply_delta (the list-containment check, and version-only first-seen-wins). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
scripts/sync-wasm.sh after the purge change: room_contract.wasm de327f61 -> 5b212594 (ui/public and cli copies identical). The chat delegate is unchanged at d88db240, so its pin stands. The V35 registry entries (main's eb2222c3 and 49a3122b) still name the outgoing generations. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
This was referenced Oct 16, 2026
sanity
commented
Oct 16, 2026
sanity
left a comment
Contributor
Author
There was a problem hiding this comment.
Review: #793 round 1 (Full tier: contract WASM, summary wire format, re-key)
HEAD reviewed: 85586d1
Reviewers run: code-first, testing, skeptical and big-picture Claude lenses; a migration and room-data-loss lens; Codex (codex review --base origin/main).
Confirmed sound
verify()andpost_apply_cleanupare identical to main. The scaffold is unchanged, so the migration PUT accepts everything it accepted before.- The V35 registry entries name the live outgoing artifacts (room
eb2222c3…, delegate49a3122b…). Both WASMs rebuild byte-identically from source. - No persisted data uses the changed summary types.
- The two-pass merge argument holds: a capped set that ends at its cap equals the open-horizon result.
- No skip or no-op path admits an unsigned or wrongly signed record, or lets a non-owner change owner data.
- On main's logic the new fork-test assertions fail, even at the default 3 seeds, so they have real detection power. Mutation testing killed every guard in the merge, ban, evidence, member, secret and upgrade paths, except the gaps listed below.
Must fix
- The new invite-chain lookup for skipping members runs before any signature check and keeps no memory between walks, so unsigned input can drive superlinear work per update. Memoize per id, and bound on raw delta length before any per-record work.
- When the open-horizon second pass exceeds the messages work bound, the whole merge returns
Err, where main's merge succeeds. This would reject a migration PUT onto a populated successor. The first-pass result should be kept instead. - Some records that
verifyaccepts are rejected byapply_delta, which leaves peers unable to merge at all. These should be skipped (and dropped in cleanup) rather than rejected, without changingverify. This is pre-existing, but it is exactly the class this PR closes. - Two valid member records for the same member with different inviters merge non-commutatively. This is pre-existing, and the fork generator cannot produce it. It needs a deterministic tie-break and a generator op.
- The configuration's owner-id check compares against the held configuration instead of the room parameters.
Should fix
- Invalid configuration edits now report success in riverctl and the UI. Validate client-side and surface the error.
- Test gaps:
- the DM-horizon half of the defect-3 fix (mutants survive)
- the UI
merge_incoming_statepath - whole-state equal-version secrets and pointers
- forged outranking pointer and secret records
- a main-era state fixture
- three-way associativity and apply-twice delta idempotence in the fork test (RFC #5320 checks both)
stale_summary_deltas_applyasserting convergence, not just "no Err"- assertion order in the honest test, and a
colliding_pairs > 0floor in Collide
- Bound checks in bans and evidence hash every record first. Raising the ban floor multiplies default-room worst-case work by 10: measure it or justify it.
- Stale docs and comments:
direct_messages.rsmodule header,member_info.rs"all five",.claude/rules/direct-messages.md,.claude/rules/contract-summary-determinism.md, and theget_response.rscomment about owner-id rejection. - PR text: "a validly signed record never returns Err" is overstated. List what still errors.
Pending decision (@sanity)
DM purge rank-wins converges, but two of a recipient's devices purging concurrently can drop one device's tombstone.
Process
buildandui-playwright-testswere red for an infrastructure reason (yankedzipwhile compiling cargo-make), fixed by #794. This branch needs a rebase onto main.- Pointer records will be signed on the final WASM.
Verdict: Needs changes. A full re-review is required after the fixes.
[AI-assisted - Claude]
… review M5) AuthorizedConfigurationV1::check_values compared owner_member_id with the configuration the peer held, so whether a configuration was adopted depended on what each peer held, and a held configuration naming the wrong owner made every later valid one fail the check. It now compares with the room's owner (the contract parameters), so the outcome is a function of the configuration alone. Every client already signs configurations with the owner's id; test fixtures that relied on the default placeholder id now set it. Merging network state into an imported room's placeholder state now adopts the network configuration (import_merge_test). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…review M2) The merge's second, open-horizon pass could carry more older messages than one messages delta may (max(max_recent_messages, 1000)), and failed the whole merge with `?` where the first pass, and main's merge, had succeeded. That would refuse a populated successor's migration PUT. - The open pass now offers at most the work bound's worth of messages, newest first; the merged room keeps only the newest anyway. - If the open pass still fails, the first pass's result stands. - MessagesV1::apply_delta counts only NEW messages against the bound, as members, bans and evidence do, and refuses a raw length past held + bound before any per-record work. - apply_delta in merge takes `self` as its (ignored) parent instead of a clone. room_merge_past_the_messages_work_bound_succeeds_and_commutes failed before this change with "Messages delta of 1001 exceeds 1000" and now asserts both merge directions end in the same state. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…review M1) MembersV1::apply_delta decides, before checking signatures, whether each new member's invite chain reaches records this peer knows. That walk had no memo, so a delta whose records chain through one another cost work quadratic in its length. Each walk now records its answer for every id on its path and stops at an id already answered, so one delta walks each id once. A delta longer than held + work bound is refused before any per-record work. unknown_chains_are_resolved_in_linear_work counts walk steps (not wall time) for a 2000-record chain: 2,001,000 steps before, at most 3n now. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
… review M3) Some records pass `verify` (which is all a stored or PUT state is checked against) but made `apply_delta` return an error: a nickname longer than max_nickname_size, a private message citing a secret version the room has no record for, a non-event public message in a private room, and an encrypted secret at a version with no record. A merge with a peer holding one then failed every time, and only its author could change that. Lowering max_nickname_size is enough to create the first. Each rule is now an admission rule applied the same way on both sides of a merge: an arriving record that fails it is skipped, and a held one is dropped (MemberInfoV1, MessagesV1 via `admissible_in_room`, RoomSecretsV1). `verify` is unchanged. ChatRoomStateV1::merge also always applies a delta, empty if the other state holds nothing new, so a merged state is always normalized; skipping it left one direction holding records the other dropped. Both passes now offer at most the messages work bound, newest first, because `verify` does not bound a state's message count. Tests: verify_apply_agreement_test (each case fails before this change with the old error, and a 1500-message state that failed on the work bound). Tests that asserted the old errors now assert the skip; the transactional secrets test and two UI fixtures that needed a failing merge now use a forged signature, which still fails. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…#703 review M4) Two validly signed member records can exist for one member, issued by different inviters. The members summary carried only ids, so peers holding different records never exchanged them, and apply kept whichever arrived first: merge(A, B) != merge(B, A). - The members summary maps each id to its record's digest, and delta offers a record whose digest differs (re-keys with the summary change). - MembersV1::resolve_records walks breadth-first from the owner over the held and incoming records: each member keeps a record at its least invite depth, and of several at that depth the lower digest. The kept records form a tree rooted at the owner, so every kept chain is valid and acyclic, a record never displaces one issued closer to the owner, and the result is the same in any arrival order. Each record is visited once (linear work); a record that is never reached is skipped, as before, and every reached record not already held has its signature checked, a bad one still failing the delta. This replaces the per-record chain walks, so a record that could only reach the owner through itself is now never stored instead of failing the delta. - Ban evidence had the same shape: a held record whose chain no longer resolves (its inviter removed by the merge) no longer blocks an incoming record whose chain does. Tests: two_records_for_one_member_resolve_the_same_way_in_both_directions fails before this change in both cases (owner vs member inviter, member vs member). The fork test's extended mode gains an operation issuing a second record for an existing member; it found the evidence case, pinned as seed (272, 2), red without the evidence change. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
A configuration that fails its value checks is now skipped by the room rather than rejected, so an edit that produced one would print or log success and change nothing. riverctl's update_config and the UI's sign_and_apply_configuration now run Configuration::check_values (the same checks the room applies, moved onto Configuration) before signing, and report the failure instead. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…cks (#703 review S2) - dm_global_cap_test: whole-state merges where a removal frees global DM slots, a purge frees a pair's slots, and the merged configuration raises the global cap. Each must end the same in both directions and keep the freed slots filled. Disabling either DM term of merge_may_have_withheld_a_survivor fails the matching test (checked). - stale_summary_deltas_apply: a stale delta must also leave a receiver that converges cleanly with the sender (first exchange commutative), and at least one stale delta must change its receiver, so the test fails if deltas apply by silently dropping their records. - honest test: the full failure report is asserted before the rejection count; stale "0 of 3000" doc fixed. - collide test: asserts some fork pair actually holds two different configurations at one version. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…review S2) - main_era_state_test: two room states produced by origin/main code (ban evidence, member info, messages; configurations tied at one version) verify, re-serialize byte for byte, are fixpoints of the new merge (merge(S, S) == S), and now converge in both merge directions. - verify_apply_agreement_test: secret version records and upgrade pointers at one version (the UI and delegate rotation paths sign different records) end the same in both merge directions; a record or pointer that is not owner-signed still fails the merge. - room_synchronizer: merge_incoming_state pulls older messages when the incoming configuration raises max_recent_messages (fails if the summary is read against the room's own configuration; checked). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…703 review S3) BansV1 and BanEvidenceV1 hashed every record of a delta to tell held from new before checking the work bound. A delta longer than held + bound cannot be within the bound however many records it re-delivers, so it is now refused first, as members and messages already are. MIN_BANS_DELTA_BOUND (100) raises a default room's per-delta worst case from 10 bans / 80 evidence records to 100 / 800. Measured: a 100-ban delta with the cleanup it triggers takes 15-18 ms natively; at the node's WASM rate documented on MAX_EVIDENCE_DEPTH that is about 0.03-0.05 s for the ban checks and at most 0.26-0.4 s for 800 evidence chain checks, inside the 5 s limit. The figures are on the constant. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…e docs (#703 review S4) An imported room's placeholder state carries a configuration the owner did not sign, at version 1, which is also where a room whose owner never edited its settings sits. With configurations ranked by (version, digest), the placeholder could win the tie-break against the room's real configuration, so a merge could keep the placeholder alongside real members and messages. The whole-state merge now offers the other side's configuration whenever the receiver's is not the owner's and the other's is, and apply_delta lets any valid configuration replace one that is not the owner's. Only a client's local state can hold one; the contract's validate_state rejects it. The cost is two signature checks per merge whose configurations differ. The unguarded callers that can see a placeholder (room_data's delegate loads, get_response's re-accept refresh) therefore adopt the real configuration when the placeholder is the receiver. When the placeholder is the incoming side and outranks, its unsigned configuration fails the merge as before (#703 changed only which tie it can win); those callers log the error and keep the room they hold. Docs brought up to date: purge resolution in direct_messages.rs and .claude/rules/direct-messages.md, the tie-rule consumers in member_info.rs, the new rank summaries in contract-summary-determinism.md, and the get_response comment that said a placeholder merge fails. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…eview S5) - ChatRoomStateV1::merge_from(params, other): the merge without the parent argument it ignores. The contract's update_state calls it, so it no longer clones the whole room state only to pass a parent. - The merge derives the configuration it ends with once (cloning the state only when the configuration changes) and reads both the summary and the messages cap from it; it used to do that twice. The two passes share one delta builder. No behaviour change; the full river-core suite passes unchanged. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…w S6) The randomized fork-merge test runs its signature checks through unoptimized ed25519/curve25519 in CI's debug `cargo test`, which took ~300 s (2.8x the pre-#703 run). Compiling curve25519-dalek, ed25519-dalek, sha2 and blake3 at opt-level 3 in the dev profile brings it to ~4-6 s, and speeds every other signature-heavy test. Release and WASM builds use their own profiles, so no committed WASM changes. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…view fixes (#703) scripts/sync-wasm.sh after M1-M5 and S3-S5: - room_contract.wasm 5b212594 -> 575feb9f (ui/public and cli copies identical) - chat_delegate.wasm d88db240 -> 86031737 (pin updated) Neither intermediate generation on this branch was published, so the V35 registry entries (main's eb2222c3 and 49a3122b) still name the outgoing generations; the --ci checks against origin/main pass. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…#703 review) Two cleanup outcomes depended on the path records took, not on the records: - A record an owner ban removes was kept as ban evidence only if it arrived as evidence: arriving as a member, MembersV1::apply_delta removed it before cleanup's snapshot. Cleanup now never keeps such a record as evidence. Nothing needs it: a ban its member issued is swept, and a ban naming it ranks as one whose target is held nowhere. - member_info was kept only for the evidence ancestors a ban reads, so a member pruned into ban evidence and later re-added came back without the record a peer where they never left still had. Cleanup now keeps member_info for every record held as ban evidence (bounded by the stored bans and the depth cap). verify() is unchanged. Tests, red before this change: ban_evidence_does_not_depend_on_how_a_record_arrived and member_info_survives_a_stay_in_ban_evidence. Two mutual_rejection tests that pinned the narrower member_info rule now pin the new one; the removed-moderator test still asserts the grant cannot void the ban. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
A bounded retention window cannot be associative once a later state can free a slot: an earlier merge has already discarded what the other grouping keeps. The doc comment on `ending` records the two minimal cases (a ban after truncation, and a raised cap with no removal at all) so the missing check reads as deliberate. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…review P3) The re-sign of a DM purge this device recorded, when the network's winning envelope omits it, ran only on the subscription paths. It now also runs on every GET path that brings network state into a held room (the re-accept refresh, the join, the imported-room replacement, the refresh merge, and a backward probe's recovered state), and the paths whose sync is not otherwise marked mark it. Applying this device's purge is RoomData::apply_own_purge (apply the envelope, record its tokens), called by "Delete their messages". Tests: a_full_state_ingestion_re_signs_a_lost_purge and a_delta_ingestion_re_signs_a_lost_purge drive the subscription paths themselves (red against a heal behind a dead branch); a_purge_is_applied_and_recorded_as_own; and a pin that every GET path heals and none sits behind a constant condition. For the native tests, the delegate save in those paths uses safe_spawn_local and the document title has a native no-op. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
The record of DM purges a client signed grew without bound and was re-signed in full: a recipient that left the room (and whose DMs went with it) kept its tokens for a later rejoin, and a record past the tombstone cap made every re-sign fail. - OwnPurges::prune drops the record of every recipient that is not a member of a state whose configuration the owner signed; the UI and riverctl prune before re-signing (riverctl persists the pruned record). - A re-signed envelope holds at most the tombstone cap; recorded tokens past it are left out rather than failing the re-sign. Tests (red before the cap fix): the_record_of_a_recipient_who_left_is_dropped, a_heal_stays_within_the_tombstone_cap, and riverctl's a_purge_record_of_a_recipient_who_left_is_dropped. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…iew S3) - own_purges: a later winning version that omits a token is re-signed again; the owner re-signs its own purge; a union keeps every purge and the later healed version, sorted and duplicate-free; a full envelope is not re-signed. - Rooms::merge unions own purges in the adopt and the same-identity branches (merging_rooms_unions_own_purges_in_both_branches). - UI call sites: the config edit is prepared by one helper (prepare_config_for_signing: version, normalization, refusal) with a test; the member-info nickname length check; the invitation dialog's Accept predicate (invitation_accept_disabled). - riverctl: the refusals run before anything is signed in invite accept, member set-nickname, room create and room config. - Bans: an issuer known only through ban evidence is known when bans no known key verifies are dropped before the cap. - Network trim: a member whose chain does not reach the owner is the first left out. Each new test was checked against a mutant of the rule it covers. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
… S4) - The doc comments of RoomData::signing_key, build_new_room_state and ApiClient::heal_member_info sat on functions inserted above them; each is back on its item. - river-publish.md lists `own-purges` among the client-only features. - StoredRoomInfo::own_purges notes that an older riverctl rewriting rooms.json drops it. - direct-messages.md describes the own-purge rules as they now stand: every UI ingestion path, riverctl recording only under the stored identity and storing a re-sign only once published, pruning of a recipient who left, and the tombstone cap. Comments and docs only. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
Test helpers and names describe what they build: a ban claiming one issuer and signed by another key (claimed_as), and bans their claimed issuer's key does not verify. Test code only. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
merge-laws.md records a limit that predates #703 (review round 5, accepted): the contract does not apply an empty delta, so a state stored by a full-state PUT that holds records the rules drop stays as stored until a non-empty delta or a whole-state merge reaches it. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…d 5) scripts/sync-wasm.sh after the round-5 river-core changes: room contract 271ba6e9, chat delegate e9b45fb4 (UI pin updated). The V36 registry entries (057ec3ae / eca06ace, main's outgoing WASMs) are unchanged. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…trim content (#703 review round 6 X5, T1) - the_work_bounds_are_back_on_after_a_whole_state_merge and its twin after a merge that fails: a network delta over the bounds is refused after merge_from and merge_through_summary (both red against a lift that is not restored). - Network-trim content: no message whose author the delta left out, messages by the owner and by listed members kept, the newest messages kept, the floor evidence bound at the least depth cap and the sender's own depth cap when the receiver takes the sender's configuration. - Refusals: a state at its own evidence bound is not refused; an unsigned configuration earns no member or evidence bound above the floors. - A ban whose issuer is known nowhere does not unseat a stored one. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…ed (#703 review round 6 X3) A whole-state merge now also refuses, before any signature is checked, an incoming state whose configuration does not outrank the receiver's when it holds more bans, members or ban evidence than one delta may carry in the receiver's room (each cap at least its per-delta floor). Without it, a replayed older owner-signed configuration with a high cap admitted that many records, each checked by the receiver. A peer still on an older, higher cap after the owner lowered it is refused once; it takes the new configuration from its own merge of the receiver's state, and the next exchange goes through. merge-laws.md documents the rule. Tests (red before): a_lower_ranked_state_over_the_receivers_bounds_is_refused (20000 bans, refused before checking signatures) and a_peer_on_an_older_higher_cap_converges_after_one_more_exchange. Tests whose stale peer held more than the new bounds allow now go through the refusal and the follow-up exchange (an `exchange` helper), the two lowering convergence tests count one refusal, two whole-state tests keep their scenarios within the bounds, and the contract's own-merge witness is now a raised message cap. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
… authors (#703 review round 6 X4) With more members missing at the receiver than one delta may carry, the messages trim (newest first) and the members trim (shallowest, lowest id) could choose disjoint sets; the author filter then left out every message, and the receiver pruned the members it took, so the same delta was sent forever. The members trim now keeps first the authors of the messages the delta keeps, with their invite chains, then ranks the rest as before. Test (red before): every_trimmed_network_delta_advances_the_receiver (2000 missing members; the first delta delivers messages, repeated exchanges deliver the whole window). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…iew round 6 T1) The contract's network delta added the member record (and invite chain) of a message author missing from the receiver's summary. The members part of the delta already carries every member the summary lacks, and the trim now keeps the authors of the messages it keeps first, so the helper added nothing. get_state_delta is delta_within_work_bounds. Test: a_new_message_arrives_with_its_authors_record (a receiver that pruned a member is sent the record with the new message, #145). merge-laws.md updated. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…heal and refusal tests (#703 review round 6 T1, T3, T4, T5) - A join refused for its nickname now presents the invitation again, so the nickname prompt with the reason shows even when the dialog was closed (or the join was resumed after a reload); presenting it stores the invitation and drops the saved nickname. - Clearing an earlier refusal on Accept is deferred (a signal write from an event handler). - Tests: a_refused_join_presents_the_invitation_again_at_the_prompt (red before), a_recovered_state_re_signs_a_lost_purge (backward-probe path), a_purge_record_of_a_member_who_left_is_dropped_before_re_signing; the GET-path pin no longer counts commented-out calls. Each checked against a mutant. - The incoming-state test now states the round-6 rule: within one delta's bound a lower-ranked state merges and the lowered cap keeps what it keeps; over it, it is refused. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…nd 6 T1) - A purge re-sign that was not published leaves nothing stored locally (asserted on the stored room). - A purge is recorded under the room's stored identity, a member key distinct from the owner's, and under neither the owner's key nor another key. - `dm purge` records through record_sent_purge, which turns a failure to record an already-sent purge into a warning; tested with a store that cannot be written. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
… is merged into (#703 review round 6 T2, T5) - refuse_beyond_own_bounds: the bound covers bans, members and evidence; messages are checked as verify checks them. It holds only the incoming side: a receiver holding a state verify refuses merges into it, which only a client's local state can hold (the contract validates every state it stores, and no apply or merge produces one), so the one-way result is unreachable on the network. - merge-laws.md and FREENET.md: a ban no known key verifies, including one whose issuer is known nowhere, never counts toward max_user_bans; FREENET.md also states the lower-ranked refusal. - own_purges: a prune keeps the owner's record and drops the healed version of a recipient that left (ported round-6 probe). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…view round 6 T1) Drives accept_invitation natively: the refusal recorded for the room is cleared and the new join is pending with the new nickname (red against a clear that does not run). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…d 6) scripts/sync-wasm.sh after the round-6 river-core and contract changes: room contract 8bfd9bcd, chat delegate 0fc3625e (UI pin updated). The V36 registry entries (057ec3ae / eca06ace) are unchanged. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
The room's whole-state merge refuses an incoming state whose configuration does not outrank the receiver's when it holds more than one delta may carry in the receiver's room. The clients' one-way local merges gave up on that refusal (keeping one side), and the invitation GET path `.expect`ed the merge and would panic. They now use merge_converging (UI room_data, and riverctl's copy): on that refusal the other side first merges the receiver (adopting the outranking configuration and trimming), then that is merged in, which is where the two peers of such an exchange end. Any other merge failure is returned as before. Covered: the backward probe's merge_generations and merge_room_states, merge_recovered_into_room, Rooms::merge (both branches), the GET paths (invitation join, re-accept refresh, refresh merge), and riverctl's merge_with_local. Client code only; the WASM is unchanged. Tests (red before): local_merges_converge_past_a_lower_ranked_refusal, copies_from_two_generations_converge_past_a_lower_ranked_refusal, and riverctl's a_local_merge_converges_past_a_lower_ranked_refusal (each also pins the refusal's wording the helpers match). Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
a_sent_purge_that_cannot_be_recorded_is_a_warning used a read-only directory, which does not fail when the tests run as root. record_sent_purge now takes the recording as a closure, and the test passes one that fails. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
Brings in #792 and the published-contract counter bump. No conflicts. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
cargo make sign-pointer-records, starting from origin/main's records: river.room-contract version 5 -> 6, code hash 8bfd9bcd...; river.chat- delegate version 6 -> 7, code hash 0fc3625e.... check-pointer-freshness passes, and the UI build embeds the matching records (the generation watch's shipped_build_watches_both_pointers passes). Signed only; the records are published from main after the merge. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
The UI convergence tests now assert that the room's merge refuses with the lower-ranked wording (LOWER_RANKED_REFUSAL) before converging. A new test covers merge_converging failing past that refusal: the refused copy takes the target's side, merging it in fails on a member record the owner did not sign, and the target is left unchanged with the refusal as the error. Checked by two mutations of merge_converging (writing the target before the fallback, and on the last step's failure); both fail it. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
fold_forward_upgrade_state now merges through merge_converging, as the other local merges do, so a followed generation the room's merge refuses once (its configuration does not outrank the base's and it holds more than one delta may carry in the base's room) is still folded in; any other merge failure still keeps the base. The new fold test was red before the change. A new test covers merge_converging failing past the lower-ranked refusal: the target is left unchanged and the error is the refusal (checked by a mutation writing the target on the last step's failure). The convergence test's fixture moves into a shared generation_copy. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
reconcile_room_present merged the stored copy with the room's plain merge, so a stored copy on an older configuration holding more than one delta may carry in the local room failed the save and left the local edits unsaved. It now merges through merge_converging, as the other local merges do; any other merge failure still fails the save. reconcile_room_present_converges_past_a_lower_ranked_refusal was red before the change. New a_merge_error_other_than_the_refusal_is_returned_at_once: an incoming copy over its own ban cap, where the converging steps would succeed, still fails with its own-limit error and leaves the target unchanged (red with the refusal guard removed). The convergence fixture moves to module level as test_generation_copy so the delegate test shares it. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
The riverctl copy of merge_converging gets the same test as the UI's: an incoming copy over its own ban cap, where the converging steps would succeed, fails with its own-limit error and the target is unchanged (red with the refusal guard removed). merge_with_local's comment now describes the converging merge it uses. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
sanity
marked this pull request as ready for review
October 17, 2026 11:53
sanity
added a commit
that referenced
this pull request
Oct 17, 2026
Publish of a7f9f17 (#793, merge-law conformance re-key). Read-back: LANDED, network serving 30000407 with the exact archive published. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #703. Part of the merge-law enforcement work (freenet/freenet-core#5906): the room contract satisfies the merge laws freenet-core checks, ahead of RFC freenet/freenet-core#5320 enforcing them.
What changes
All room-contract changes ship together as one re-key.
057ec3ae…, delegateeca06ace…).8bfd9bcd…, delegate0fc3625e…. Pointer records re-signed: room v6, delegate v7.Merging
ChatRoomStateV1::merge/merge_fromis the room's own commutative whole-state merge, shadowing the trait method, and the contract'supdate_stateuses it. A receiver that merges through its own summary (the UI) usesmerge_through_summary.(version, signature digest), and summaries carry that rank. The bans summary carries a whole-record digest.merge(S, S).Work bounds
Nicknames and configuration
max_nickname_sizeis a client rule: the UI and riverctl check it before signing on edit, join and create.Client side
river_core::own_purges, client-only feature) and re-sign the union if a concurrent envelope from the member's other device wins. The UI and riverctl produce byte-identical envelopes. This was decided by @sanity.verify()is unchanged, so migrated state needs no conversion.Behaviour to know
owner_member_idmust equal the room owner's.Testing
FORK_SEEDS=1000, 7 of 7 passing: honest, adversarial, extended, collide, stale deltas, rich (secrets, private rooms, DMs, purges, pointers) and split delivery. Each mode requires a commutative first exchange, convergence, andmerge(S, S)fixpoints.cargo make test793; river-ui 1307; riverctl 452. Playwright (chromium): 268 passed, 7 skipped.check-migration,check-room-contract-migrationandcheck-pointer-freshnesspass.Known limits, tracked for the follow-up
After merge (from main)
publish-river.publish-pointer-records.riverctl-autoupdate.sh.https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
[AI-assisted - Claude]