Skip to content

fix(room): merge-law conformance for the room contract (#703) - #793

Merged
sanity merged 129 commits into
mainfrom
fix/703-merge-laws-v2
Oct 17, 2026
Merged

sanity merged 129 commits into
mainfrom
fix/703-merge-laws-v2

Conversation

@sanity

@sanity sanity commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • Versions: river-core 0.3.0, riverctl 0.2.23.
  • Registry: V36 entries name main's outgoing WASMs (room 057ec3ae…, delegate eca06ace…).
  • New WASMs: room 8bfd9bcd…, delegate 0fc3625e…. Pointer records re-signed: room v6, delegate v7.

Merging

  • One room merge. ChatRoomStateV1::merge / merge_from is the room's own commutative whole-state merge, shadowing the trait method, and the contract's update_state uses it. A receiver that merges through its own summary (the UI) uses merge_through_summary.
  • Ranks. The configuration, upgrade pointer, secret version records and DM purge envelopes resolve by (version, signature digest), and summaries carry that rank. The bans summary carries a whole-record digest.
  • Skip instead of reject. A record that is validly signed but not admitted is skipped on arrival and dropped where held, by the same rule in both places. This covers stale or re-delivered records, records over a cap, a second record for a held member id, and a public message in a private room. A merge returns an error only for a record no known key verifies, or for exceeding a work bound.
  • Normalization. Held state is normalized on every apply: duplicate entries, secret blobs for departed members, and duplicate secret version records. Every state an apply or merge produces is a fixpoint of merge(S, S).

Work bounds

  • Whole-state merges apply the incoming state in full. Before any signature check, a state is refused if it exceeds its own owner-signed caps, or if its configuration ranks below the receiver's and it exceeds the receiver's bounds.
  • The contract's network delta is trimmed to the per-delta bounds. It keeps the kept messages' authors and their invite chains, and the rest follows in later rounds.

Nicknames and configuration

  • Nicknames: the room holds nicknames up to a fixed 1000-byte ceiling. max_nickname_size is a client rule: the UI and riverctl check it before signing on edit, join and create.
  • Configuration: a configuration that fails the value checks ranks below every valid one. Clients check before signing.

Client side

  • DM purges: the highest-ranked envelope wins. The contract no longer requires a higher-version envelope to contain the held list. Clients record the purges they sign (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.
  • Local merges in the UI and riverctl converge past a work-bound refusal by merging the other way first.

verify() is unchanged, so migrated state needs no conversion.

Behaviour to know

  • Switching a room from public to private drops its held public messages.
  • A configuration's owner_member_id must equal the room owner's.
  • Sending in a private room without the room secret is blocked in the UI with a notice.
  • A peer holding a lower-ranked configuration above the receiver's bounds is refused once, then converges on the next exchange.

Testing

  • Fork-merge test at 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, and merge(S, S) fixpoints.
  • Randomized merge-law fuzzing over valid crafted states, more than 10,000 pairs per run: no one-shot commutativity failures outside the documented residual below, and no network delta refused for size.
  • Captured Official-room states: all 25 are fixpoints, all 300 pairs commute, none is refused, and the stored main-era transitions reproduce.
  • Suites: cargo make test 793; river-ui 1307; riverctl 452. Playwright (chromium): 268 passed, 7 skipped.
  • WASM: rebuilding reproduces the committed bytes.
  • Migration: check-migration, check-room-contract-migration and check-pointer-freshness pass.

Known limits, tracked for the follow-up

  • When two peers hold different records for one member id, each keeps its own.
  • Some member decisions are taken at apply time before cleanup's caps, so a re-merge can still change a result in rare cases. Each heals on the next exchange.
  • Three-way associativity cannot hold for bounded retention; freenet-core#5915 makes that law heal-guarded.

After merge (from main)

  1. Rehearse the migration on an isolated node.
  2. publish-river.
  3. About two minutes later, publish-pointer-records.
  4. Tag riverctl.
  5. On nova, run riverctl-autoupdate.sh.

https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG

[AI-assisted - Claude]

sanity added 11 commits October 16, 2026 01:09
…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

@sanity sanity left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and post_apply_cleanup are 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…, delegate 49a3122b…). 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

  1. 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.
  2. 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.
  3. Some records that verify accepts are rejected by apply_delta, which leaves peers unable to merge at all. These should be skipped (and dropped in cleanup) rather than rejected, without changing verify. This is pre-existing, but it is exactly the class this PR closes.
  4. 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.
  5. 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_state path
    • 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_apply asserting convergence, not just "no Err"
    • assertion order in the honest test, and a colliding_pairs > 0 floor 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.rs module header, member_info.rs "all five", .claude/rules/direct-messages.md, .claude/rules/contract-summary-determinism.md, and the get_response.rs comment 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

  • build and ui-playwright-tests were red for an infrastructure reason (yanked zip while 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]

sanity added 16 commits October 16, 2026 03:40
… 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
sanity added 25 commits October 16, 2026 22:06
…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
sanity marked this pull request as ready for review October 17, 2026 11:53
@sanity
sanity merged commit a7f9f17 into main Oct 17, 2026
8 checks passed
@sanity
sanity deleted the fix/703-merge-laws-v2 branch 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Merge-law defects in the room contract: equal-version configs, delta re-delivery, horizon on cap change

1 participant