Repository navigation
fix(contract): evict bans at max_user_bans by age within owner and other pools (#796) - #797
Conversation
…her pools (#796) Once the ban list was full of owner bans, a deputy's new ban ranked lowest under #702's tiering and was evicted the moment it was added, so deputies could ban nobody. In the Freenet Official room the list sat at 200/200 owner bans (the moderator bot and the autoban sweep sign with the owner key) and a deputy ban from the UI silently did nothing. The cap now evicts by recency (Ian, 2026-10-16): - bans that take no effect, and later duplicates of an (issuer, target) pair, go first, oldest first; - live bans are split into owner bans and all other bans; each pool is guaranteed half the cap, only a pool's bans beyond its guarantee can be evicted, and the oldest of those go first. The guarantee is a floor, so a room with few owner bans gives other bans nearly the whole cap. Pure recency over one pool was rejected: banned_at is issuer-signed and the contract has no clock, so far-future-dated bans would let any member flush every ban, owner bans included. With two pools a flood churns only the other pool's surplus. The residual that a member with ban authority can evict non-owner bans with future-dated bans remains (#413).
…re 0.2.1, riverctl 0.2.22) Registers the outgoing room-contract (eb2222c3...) and chat-delegate (49a3122b...) generations as V35, rebuilds both WASMs, and re-signs the pointer records.
… docs, more cap tests - Each pool is guaranteed max_user_bans / 2; an odd cap's spare slot goes to the newer ban (at cap 1, plain recency). Before, the spare went to the non-owner pool, which is the one an attacker can fill. - Docs: any member can mint live future-dated bans (against its invitees or ids known nowhere), which evict older non-owner bans and owner bans beyond the newest half-cap. State it, and how to protect every owner ban. - Tests: odd cap, cap 1, a dead entry plus pool eviction in one pass with input-order independence and idempotence, the DM path deciding by pools, and a pin for the plain-member random-target flood. - Rebuilt room-contract WASM and re-signed pointer records.
sanity
left a comment
There was a problem hiding this comment.
PR review #797, round 1 (HEAD reviewed: 8d39e58)
Tier: Full, because this changes contract merge semantics and ban authorisation and re-keys the room contract and chat delegate.
Reviewers: four independent Claude reviewers (code-first, testing, skeptical, big-picture), each reading blind, plus Codex (codex review --base origin/main). Codex completed and reported no findings.
Consensus
- The algorithm is sound: deterministic, independent of input order, idempotent at the cap, and with no panic path. The candidate set always covers
to_remove. enforced_ban_set_of(DM apply) still calls the same function, so the two sites agree by construction.- Re-key bookkeeping is consistent: the V35 hashes equal the previous pointer hashes, the pointer records were re-signed, and versions and changelog were bumped.
- No test was silently dropped. The one inverted assertion (the sybil flood) is explicit.
Findings and resolution (fixed in 73bad66)
-
Residual understated (skeptical, code-first, big-picture). The docs said a flood needs "a member with ban authority". In fact any member can mint live, far-future-dated bans, either against its own invitees or against ids whose record is held nowhere. Such a flood evicts older non-owner bans, as it already could under #702, and also owner bans beyond the newest
max_user_bans / 2, which is new. Fixed: this is now stated plainly inban.rs,room_state.rsand the CHANGELOG, with the mitigation: banning the flooder makes their bans dead, andmax_user_bans >= 2 x owner bansprotects every owner ban. Ranking mutual bans lower would not close it, because inviter bans give the same reach. -
Odd caps (testing, skeptical).
owner_share = max/2gave the spare slot to the non-owner pool, which is the one an attacker can fill. At cap 1 the owner got no guarantee. Fixed: both pools are now guaranteedmax/2, the spare goes to the newer ban, and cap 1 is plain recency. Tested at cap 3 and cap 1. -
Test gaps (testing). Added:
- a dead entry plus pool eviction in one pass, with reversed and rotated input and a second pass, all giving the same result;
- the DM path deciding by pools (
dm_ban_derivation_applies_the_cap_pools, which fails under pure recency); - a pin for the plain-member random-target flood.
Mutation check: setting the share to 0 (pure recency) fails five tests.
-
Stale comments (code-first, testing). The step 0-cap comment in
room_state.rsand the doc oncycle_bans_rank_mutual_after_an_earlier_removalstill described tier ranking. Fixed.
Not changed
- Surfacing a ban the cap drops in the UI/CLI (skeptical #3). This is a separate UX change, left on #796.
- Coordination with #793: it also claims V35 and riverctl 0.2.22. Whichever PR lands second must chain its "before" hash to the other PR's output and re-sign the pointer records, not just renumber.
Verdict: Needs Changes, re-review required after the fixes. A full re-review on 73bad66 follows.
[AI-assisted - Claude]
… review round 2 - The cap pass checks each record's own signature against its claimed issuer's key before using any BanId-keyed verdict; a record that does not verify is dead. The canonical sort breaks (banned_at, id) ties by record digest, so eviction never depends on arrival order. - Pointer records re-signed from main's versions (room contract 4 -> 5, chat delegate 5 -> 6); the previous commit had skipped one. - Tests: surplus dead entries, several evictions across both pools. - CHANGELOG and docs state concretely that a lifted owner ban lets the person rejoin.
- Precise wording: an odd cap's spare slot goes to the newer candidate only when both pools are over their share; the guarantee is floor(cap/2); banning a flooder does not restore bans already pushed out. - Reconcile the input-order claim with the CONVERGENCE HONESTY note. - Rename the even-cap multi-eviction test to what it checks. - Tests: an inert ban goes before older live bans; a ban on an id held nowhere by an absent issuer is dead. - Rebuilt WASMs (line numbers moved) and re-signed pointer records from main's versions (room contract 4 -> 5, chat delegate 5 -> 6).
sanity
left a comment
There was a problem hiding this comment.
PR review #797, rounds 2 and 3 (final HEAD 09790a3)
Tier: Full. Lenses per round: code-first, testing, skeptical and big-picture, each reading blind.
External model:
- Codex (
codex review --base origin/main) completed rounds 1 and 2 with no findings. - In round 3 it failed twice with
ERROR: Selected model is at capacity(HTTP 503 from OpenAI). Per policy, the four Claude lenses stood in for it. - The final diff after round 3 (docs and tests only; logic and contract WASM unchanged) had a separate light re-check.
Round 2 (73bad66): resolved in f32d843
- Pointer record version skipped. The room-contract pointer went 4 → 6, so it was re-signed from main's file: room contract 4 → 5, chat delegate 5 → 6.
- Ban-record classification hardened. The cap step now judges each stored ban record by its own signature against its claimed issuer, and treats a record that fails as dead. Its sort breaks
(banned_at, id)ties by record digest, so the result never depends on arrival order. Round 3's skeptical reviewer confirmed that this cannot misclassify a genuine ban, including bans by issuers held only in ban evidence and bans by owner-removed issuers. - Doc and test gaps: fixed.
Round 3 (f32d843): resolved in 530bc13 / 09790a3
- Doc precision:
- the odd-cap spare slot goes to the newest ban beyond either pool's share;
- each pool's guarantee is
floor(cap/2), so nothing at cap 1; - banning a flooder does not restore bans already pushed out;
- the input-order claim is reconciled with the CONVERGENCE HONESTY note.
- Tests added:
- an inert ban goes before older live bans;
- a ban on an id held nowhere, by an issuer known nowhere, is dead;
- surplus dead entries go oldest first;
- several evictions across both pools.
- One misleadingly named test was renamed.
- Coordination: notes posted on #793 (V36 must chain from this PR's hashes), #792 (clean merge; its tab reload is triggered by this PR's pointer bump) and #758.
Deferred, with reasons
- The CLI
debugban classification has a similar display-only issue. It never affects stored state, because cleanup step 4 checks every record. Follow-up after publish. - A further pre-existing hardening item was found outside this change's scope. It is being handled separately.
Verdict: Ready to merge once CI is green on 09790a3.
[AI-assisted - Claude]
Both pins are documented to move with a new legacy-registry generation: the room-contract registry fingerprint (35 entries) and the committed chat-delegate WASM hash (eca06ace...).
…to fix/703-merge-laws-v2 Conflict resolutions: - Cargo.toml, Cargo.lock, cli/Cargo.toml: keep river-core/river-ui 0.3.0 from this branch; riverctl becomes 0.2.23 because main released 0.2.22 (cli/CHANGELOG.md: this branch's entry moves to 0.2.23, main's 0.2.22 entry kept below it). - Registries: main's V35 entries kept as main wrote them (both sides had registered the same outgoing hashes, eb2222c3 and 49a3122b). This branch's generation becomes V36, naming #797's committed outgoing WASMs: room 057ec3ae, delegate eca06ace (delegate_key 67c126f5), matching main's committed WASMs and pointer-records.toml. Registry pin 36 entries, fingerprint 93b6bba9b8d1beb3. - WASMs and the delegate pin: main's, to be rebuilt in the next commit. - ban.rs, room_state.rs, member.rs merged without conflict: #797's enforce_user_ban_cap is taken as is. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
The whole-state merge trims a bans delta over the work bound. It kept owner bans first, which matched the old tiered cap. #797's cap guarantees owner bans and all other bans half the cap each and evicts the oldest beyond that, so the trim now keeps the newest of each pool in turn: the kept set holds each pool's newest bound/2, which covers what the merged cap keeps from either pool. The unit test adds a case with both pools over half the bound. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
… bans apart (#796, #703) #797's cap eviction judges each ban live or dead, and owner or other, from state outside the ban list, so two peers holding full ban lists can classify the same ban differently before they merge. The new test grows pairs of forks from a common base with owner bans (some on banners, killing their bans on one side only), a deputy's bans, inviter bans and bans on ids held nowhere, at caps 2 to 5, and requires merge(A, B) == merge(B, A), a verifying result, merge(M, M) == M and a cleanup fixpoint. Floors keep the generator honest (156 of 160 default pairs disagree on removals, 158 put the ban union over the cap). It fails (9 of 160) if bans are capped at apply time against the receiver's pre-update members. It passes at 2000 pairs both on this branch and on origin/main alone. The fork test now also requires a run whose forks together hold owner and other bans over the cap, in honest and adversarial modes. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
scripts/sync-wasm.sh after merging origin/main: room_contract.wasm 057ec3ae (main, #797) -> afdc9db5 (both copies identical); chat_delegate.wasm eca06ace -> c415d92a, pin updated. V36 names the outgoing generations (room 057ec3ae, delegate eca06ace). Neither WASM imports wbindgen. pointer-records.toml still names main's WASMs: the records are not re-signed on this branch. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
…ebug bans the same way (#800) * test(contract): pin per-record signature checks at the ban cap; fix debug bans the same way #797 made enforce_user_ban_cap judge each ban record by its own signature, because a BanId hashes the signature alone and records sharing an id were credited with each other's verdicts. Its regression tests were held back until the fixed contract was published (live at 30000404). They cover the owner pool, first-of-pair, and the unknown-target live branch, each in two input orders and comparing whole records; all three fail on the code before the fix. riverctl's debug bans classifier read the same id-keyed verdicts; it now checks each record's own signature first. Display only. * test: pin each per-record guard at the ban cap on its own (#800 review) - The first-of-pair test gets an older competing live ban, so it fails if only the first-of-pair filter is removed. - New: a copy differing only in owner_member_id matches the genuine ban's first-of-pair rank, so only the per-record check in live() kills it; it fails if only that check is removed. - Record comparison includes owner_member_id. - Rewrap the classify_ban doc.
Fixes #796.
Problem
Once the ban list is full of owner bans, every new deputy ban is evicted as soon as it is added. #702's cap ranked owner bans above all others, so in a full list of owner bans a deputy's ban was always the lowest-ranked entry. In the Freenet Official room the list sat at 200/200 owner bans (the moderator bot and the nickname autoban sweep both sign with the owner key). A deputy ban on a spammer, made in the UI, appeared to succeed and did nothing.
Change
BansV1::enforce_user_ban_capnow evicts by recency, following @sanity's direction:BanResolution::INERT) and later duplicates of an(issuer, target)pair. This classification is unchanged from fix(contract): stop an orphaned ban from forking a room permanently (#423) #702.max_user_bans / 2slots. Only a pool's bans beyond its guarantee can be evicted, and the oldest of those go first, by(banned_at, id). With an odd cap the spare slot goes to the newer ban, so at cap 1 this is plain recency. The guarantee is a floor, not an allocation: owner bans are often rare, so the other pool can use slots the owner pool leaves free.Why not one pool by pure recency:
banned_atis signed by the issuer and the contract has no clock, so far-future-dated bans always count as newest. Under pure recency any member could flush every ban, owner bans included.Residual, stated plainly. Any current member, with no moderator rights, can mint live, far-future-dated bans: against throwaway members they invited, or against random ids whose record is held nowhere. Such a flood evicts:
max_user_bans / 2. This is new, since under fix(contract): stop an orphaned ban from forking a room permanently (#423) #702 owner bans were immune.Banning the flooder makes all their bans dead, so the next cap pass drops them; bans already evicted stay evicted. A room that wants every owner ban protected sets
max_user_bansto at least twice its owner-ban count. In the Freenet Official room that means 400 or more, since it holds 200 owner bans today.enforced_ban_set_of(DM apply) calls the same function, so the two sites still agree by construction.Re-key
This changes the room contract and the chat delegate (which links river-core's room state), so the PR re-keys both:
common/legacy_room_contracts.toml(eb2222c3…) andlegacy_delegates.toml(49a3122b…), added from the committed WASMs before the rebuildroom_contract.wasm(ui + cli) andchat_delegate.wasmviascripts/sync-wasm.shpointer-records.tomlThere is no state-format change. Stored states keep deserializing, and a state at or under the cap is untouched.
Heads-up for #793, which also claims V35 and riverctl 0.2.22: whichever PR merges second needs to renumber.
Tests
a_full_list_of_owner_bans_does_not_evict_a_new_deputy_ban: the Full ban list of owner bans silently evicts every new deputy ban #796 regression; fails on main.rare_owner_bans_leave_the_whole_cap_to_other_bansanda_new_owner_ban_evicts_the_oldest_ban_of_the_pool_over_its_share.a_flood_leaves_the_newest_half_cap_of_owner_bansanda_plain_members_random_target_flood_evicts_owner_bans_down_to_the_share: pin the residual.an_odd_caps_spare_slot_goes_to_the_newer_banandcap_one_keeps_the_newer_ban_whatever_its_pool.a_dead_entry_and_a_pool_eviction_in_one_pass: input-order independence and idempotence.dm_ban_derivation_applies_the_cap_pools: the DM path decides by pools.cap_evicts_a_duplicate_before_any_live_ban; and three fixtures (cleanup_is_idempotent_over_cap_evicted_ban,dm_ban_derivation_must_apply_the_user_ban_cap,ban_evicted_by_the_ban_cap_keeps_the_targets_messages) whose eviction premise relied on owner-first ranking. Each still tests its original property.cargo test -p river-core(all suites),-p riverctl --lib,-p chat-delegate, plus the migration, room-contract-migration and delegate-key tests pass locally. Pointer freshness and both migration checks pass.[AI-assisted - Claude]