river_core/room_state.rs
1pub mod ban;
2pub mod configuration;
3pub mod content;
4pub mod direct_messages;
5pub mod dm_body;
6pub mod identity;
7pub mod member;
8pub mod member_info;
9pub mod message;
10pub mod privacy;
11pub mod secret;
12pub mod upgrade;
13pub mod version;
14
15use crate::room_state::ban::BansV1;
16use crate::room_state::configuration::AuthorizedConfigurationV1;
17use crate::room_state::direct_messages::DirectMessagesV1;
18use crate::room_state::member::{MemberId, MembersV1};
19use crate::room_state::member_info::MemberInfoV1;
20use crate::room_state::message::MessagesV1;
21use crate::room_state::secret::RoomSecretsV1;
22use crate::room_state::upgrade::OptionalUpgradeV1;
23use crate::room_state::version::StateVersion;
24use ed25519_dalek::VerifyingKey;
25use freenet_scaffold_macro::composable;
26use serde::{Deserialize, Serialize};
27use std::collections::HashSet;
28
29#[composable(post_apply_delta = "post_apply_cleanup")]
30#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
31pub struct ChatRoomStateV1 {
32 // WARNING: The order of these fields is important for the purposes of the #[composable] macro.
33 // `configuration` must be first, followed by `bans`, `members`, `member_info`, `secrets`,
34 // and then `recent_messages`.
35 // This is due to interdependencies between the fields and the order in which they must be applied in
36 // the `apply_delta` function. DO NOT reorder fields without fully understanding the implications.
37 //
38 // Pinned by `field_declaration_order_puts_members_before_direct_messages`
39 // (freenet/river#671). `direct_messages` must come AFTER `members`, because
40 // `DirectMessagesV1::apply_delta` sweeps its held set against
41 // `parent_state.members` and the macro derives its apply order from this
42 // declaration order — reorder them and the DM field would test its held set
43 // against the PRE-update member set, silently restoring the #671 data loss.
44 // The comment above has flagged this as fragile since the file was written
45 // and was never actually asserted.
46 /// Configures things like maximum message length, can be updated by the owner.
47 pub configuration: AuthorizedConfigurationV1,
48
49 /// A list of recently banned members, a banned member can't be present in the
50 /// members list and will be removed from it ifc necessary.
51 pub bans: BansV1,
52
53 /// The members in the chat room along with who invited them
54 pub members: MembersV1,
55
56 /// Metadata about members like their nickname, can be updated by members themselves.
57 pub member_info: MemberInfoV1,
58
59 /// Secret distribution for private rooms. Must come before recent_messages so message
60 /// validation can check secret version consistency.
61 pub secrets: RoomSecretsV1,
62
63 /// The most recent messages in the chat room, the number is limited by the room configuration.
64 pub recent_messages: MessagesV1,
65
66 /// In-room encrypted direct messages between members (#230 Phase 1).
67 /// `#[serde(default)]` keeps states written before this field was added
68 /// backwards-compatible.
69 #[serde(default)]
70 pub direct_messages: DirectMessagesV1,
71
72 /// If this contract has been replaced by a new contract this will contain the new contract address.
73 /// This can only be set by the owner.
74 pub upgrade: OptionalUpgradeV1,
75
76 /// State format version for migration compatibility.
77 /// Defaults to 0 for backward compatibility with states created before versioning.
78 #[serde(default)]
79 pub version: StateVersion,
80}
81
82impl ChatRoomStateV1 {
83 /// Post-apply cleanup: prune members who have no recent messages, clean up
84 /// member_info for pruned members, remove orphaned bans, and sweep
85 /// direct messages whose participants are no longer in the room.
86 ///
87 /// Members are kept if they have at least one message in recent_messages,
88 /// are a sender/recipient of a currently-held direct message (see
89 /// [`crate::room_state::direct_messages::DirectMessagesV1::participants_of_surviving_dms`]),
90 /// or are in the invite chain of someone who qualifies. The owner is
91 /// never in the members list (they're implicit via parameters).
92 ///
93 /// Bans are only removed if the banner was themselves BANNED (orphaned ban).
94 /// If the banner was merely pruned for inactivity, their bans persist.
95 ///
96 /// IDEMPOTENCE / CONVERGENCE INVARIANT: this function MUST be idempotent
97 /// (`cleanup(S) == cleanup(cleanup(S))`) and a pure function of the converged
98 /// state, because Freenet runs it a variable number of times across peers
99 /// (and full-state PUTs bypass it via `verify`). The `max_user_bans` cap is
100 /// therefore applied at the TOP (step 0-cap) so ban enforcement and the
101 /// banner-prune exemption read the FINAL surviving ban set — see the block
102 /// comments below and #411 round 7 / Codex P1 #1+#2.
103 ///
104 /// Direct-message sweep: after pruning, any DM whose sender or
105 /// recipient is now non-member or banned is dropped. Without this,
106 /// adding a ban for a DM participant would silently make every
107 /// peer's verify fail, and members referenced only by a DM would be
108 /// pruned (orphaning their DMs). See
109 /// `direct_messages.rs` module docs, "Interaction with bans".
110 pub fn post_apply_cleanup(&mut self, parameters: &ChatRoomParametersV1) -> Result<(), String> {
111 let owner_id = MemberId::from(¶meters.owner);
112
113 // Snapshot for the equality assert below. Taken HERE, before step 0-cap
114 // mutates `self.bans`, because this is the one point where `self` still
115 // holds exactly what `parent_state` held when the `direct_messages`
116 // field applied — the four inputs `enforced_ban_set_of` reads
117 // (`configuration`, `bans`, `members`, `member_info`) are all final by
118 // then and none is touched between there and here.
119 #[cfg(debug_assertions)]
120 let dm_apply_time_ban_set = {
121 let snapshot = self.clone();
122 crate::room_state::direct_messages::enforced_ban_set_of(&snapshot, parameters)
123 };
124
125 // 0-cap. Enforce `max_user_bans` FIRST — BEFORE ban enforcement (step 0)
126 // and the banner inactivity-prune exemption (step 2) — so both read
127 // the FINAL surviving (post-cap) ban set (#411 round 7 / Codex P1
128 // #1+#2). Running the cap here, on the converged pre-enforcement
129 // state, is what keeps `post_apply_cleanup` IDEMPOTENT and identical
130 // across peers:
131 // * #1: an over-cap ban that WILL be evicted must not one-shot a
132 // member removal at step 0 that the capped converged state
133 // cannot reproduce — a peer that only ever sees the capped state
134 // (e.g. via a full-state PUT that bypasses cleanup) would keep
135 // that member, so removing it here would diverge the member set.
136 // * #2: a banner whose ban the cap evicts must NOT be exempted
137 // from inactivity-prune at step 2. If it were (as when the cap
138 // ran last), the banner is kept on pass 1 but its ban is then
139 // evicted, so pass 2 prunes it — `cleanup(S) != cleanup(cleanup(S))`,
140 // which permanently diverges peers that run cleanup a different
141 // number of times.
142 // Eviction drops INERT (currently-unauthorized) bans before
143 // enforcing ones (#410 review round 1). This bounds an INERT flood
144 // (forged / revoked-deputy bans, which `verify` accepts) — those are
145 // evicted first, so a flood of them cannot push real moderator bans
146 // out of the cap. It does NOT fully defend the un-ban DoS: an
147 // ENFORCING-absent-target flood still evicts real bans, because a ban
148 // by a current member of an ABSENT target classifies "enforcing"
149 // WITHOUT any authorization check (`ban_is_enforcing` returns true for
150 // a member-banner + absent target), and `banned_at` is an
151 // attacker-signed, future-datable field — so a member can mint many
152 // newest-dated "enforcing" bans that outrank and evict genuine ones.
153 // The substantive fix (an authorization-aware / non-attacker-ordered
154 // cap) is deferred pending Ian's decision; tracked in
155 // freenet/river#413 (Limitation 2).
156 //
157 // CONVERGENCE HONESTY: the enforcing/inert classification is a
158 // deterministic function of the member set it runs against, but that
159 // member set is the INTERMEDIATE (pre-step-0) set as `apply_delta`
160 // left it, which is order-dependent (cascade removal is arrival-order
161 // sensitive). So two peers applying the same delta multiset in a
162 // different order can evict DIFFERENT bans and NOT be byte-equal
163 // without a further exchange; anti-entropy `merge` reconciles them to
164 // the same top-set (River's ban summary is a full BanId set, so
165 // anti-entropy always converges — pinned by
166 // `cap_eviction_reconciles_via_merge`; tracked in freenet/river#413,
167 // Limitation 1). Do NOT claim the eviction is order-independent per
168 // delta.
169 // `verify`'s hard cap ceiling still rejects any stored state left over
170 // the cap. The signature sweep (step 5) still runs AFTER enforcement
171 // to drop bans orphaned by a banner's removal, and only removes bans,
172 // so the ban count stays <= the cap.
173 let max_bans = self.configuration.configuration.max_user_bans;
174 {
175 // The eviction ORDERING lives in `BansV1::enforce_user_ban_cap` —
176 // one definition, shared with `DirectMessagesV1::apply_delta`,
177 // which needs the same surviving ban set to decide which held DMs
178 // the caps may rank (freenet/river#675). A DM swept at apply time
179 // against a DIFFERENT surviving ban set than this step produces is
180 // data loss, so the two must agree by construction rather than by
181 // two copies happening to match — which is exactly what drifted in
182 // #671 and in #411 round 4.
183 let members_by_id = self.members.members_by_member_id();
184 BansV1::enforce_user_ban_cap(
185 &mut self.bans.0,
186 max_bans,
187 &members_by_id,
188 &self.member_info,
189 owner_id,
190 ¶meters.owner,
191 );
192 }
193
194 // 0. Enforce bans from the CONVERGED (now capped) state, deputy-aware (#410).
195 //
196 // `MembersV1::apply_delta` already removed members banned by the owner
197 // or an ancestor, but it ran BEFORE the sibling `member_info` field
198 // (which carries deputy grants) was applied, so it could not evaluate
199 // deputy authority. This pass runs after every field has been applied,
200 // so `self.member_info` is converged: it removes members banned by a
201 // currently-authorized deputy, and — crucially — does NOT remove
202 // members whose deputy was revoked (the deputizer removed them from
203 // `MemberInfo.deputies` at a higher version). Because the removal set
204 // is a pure function of the converged (members + deputies + bans)
205 // state, and bans stay an add-only CRDT (never pruned here), every peer
206 // converges to the same member set regardless of delta order. Kept in
207 // post_apply_cleanup (NOT verify) so verify stays stable across
208 // ban/deputy changes — mirrors the DM ban-sweep precedent.
209 let enforced_banned_ids =
210 self.members
211 .banned_member_ids(&self.bans, &self.member_info, parameters);
212
213 // THE load-bearing invariant of the freenet/river#675 remedy: the ban
214 // set `DirectMessagesV1::apply_delta` derived, before this function
215 // ran, must EQUAL the one step 0 computes here. The apply-time sweep
216 // deletes DMs against its set and step 6 re-tests against this one; if
217 // they diverge, the apply-time sweep can delete a DM step 6 would have
218 // kept, which is permanent user data loss with no error anywhere.
219 //
220 // Asserted rather than argued, because this PR's whole thesis is that
221 // predicate agreement must not be left to two copies agreeing in
222 // comments. `dm_ban_derivation_must_apply_the_user_ban_cap` catches the
223 // known way to break it at one explicit site; this catches ANY way, at
224 // every site the existing suite already exercises — including a future
225 // change to field-application order, or to when `member_info`
226 // converges, which no test would otherwise notice.
227 //
228 // `debug_assert` so the release WASM pays nothing.
229 #[cfg(debug_assertions)]
230 debug_assert_eq!(
231 dm_apply_time_ban_set, enforced_banned_ids,
232 "enforced_ban_set_of (at DM apply) diverged from step 0's enforced_banned_ids; \
233 the apply-time DM sweep and the step-6 sweep are no longer the same predicate \
234 (freenet/river#675)"
235 );
236 self.members
237 .members
238 .retain(|m| !enforced_banned_ids.contains(&m.member.id()));
239
240 // 1. Collect message author IDs + DM participants + secret recipients.
241 //
242 // Secret recipients (i.e. members for whom the owner has issued an
243 // `encrypted_secrets` blob AT THE CURRENT VERSION) are exempt
244 // from inactivity-prune. The owner explicitly chose to issue
245 // them a per-version room secret, so the owner clearly considers
246 // them a member — and post_apply cleanup running on an
247 // invitee's first state ingestion (which arrives before the
248 // invitee has authored any join_event) must not silently delete
249 // that membership. See issue #110 / Bug #3 PR B (Ivvor
250 // 2026-05-17).
251 //
252 // The exemption is restricted to recipients at `current_version`
253 // so cleanup still prunes genuinely-inactive members whose
254 // blobs are only present at older versions (a member who joined,
255 // received v0, never authored anything, and was never re-issued
256 // a blob at v1+ is "stale" by the same definition as a member
257 // who joined and never authored). Without this scoping the
258 // exemption would keep every ever-recipient + their entire
259 // invite chain ancestor set exempt from cleanup forever,
260 // defeating the prune. See IMPORTANT item #5 on PR #272
261 // review round 2.
262 let message_authors: HashSet<MemberId> = self
263 .recent_messages
264 .messages
265 .iter()
266 .map(|m| m.message.author)
267 .collect();
268 // DM participants are counted only from DMs that will SURVIVE the
269 // step-6 sweep below — both endpoints a live, non-enforced-banned
270 // member (owner implicit). Counting every held DM (the old
271 // `active_participants()`) made this exemption strictly weaker than
272 // the sweep that runs later in the SAME pass, so a member held alive
273 // only by a DM with a banned counterparty was kept on pass 1 and
274 // pruned on pass 2 — `cleanup(S) != cleanup(cleanup(S))`, measured on
275 // live Official-room state in freenet/river#671. Same defect and same
276 // remedy as the banner exemption in #411 round 4: the exemption
277 // predicate must BE the sweep predicate, not merely resemble it, and
278 // both now call `dm_endpoint_is_live`. Not circular — see the note on
279 // `participants_of_surviving_dms`.
280 //
281 // The member set passed here is post-step-0 (enforced-banned members
282 // are already gone) and pre-step-3, which is exactly what step 6 will
283 // see for every id this loop can add: a counted participant lands in
284 // `required_ids` and therefore survives step 3 unchanged.
285 //
286 // `enforced_banned_ids` is passed but is PROVABLY INERT here, and that
287 // is deliberate rather than an oversight: step 0 has already removed
288 // every enforced-banned id from `self.members`, so
289 // `members_after_ban_enforcement` and `enforced_banned_ids` are
290 // disjoint and `dm_endpoint_is_live` never reaches the ban check at
291 // this site. Verified by a disjointness `debug_assert!` run in debug
292 // profile across the whole suite and all 25 captured live states — it
293 // never fired. It is kept because passing it makes this predicate
294 // IDENTICAL to step 6's by construction, which is the entire property
295 // this fix is about; dropping it would restore the two-copies shape
296 // that drifted apart in the first place. It also means this call site
297 // adds NO new coupling to freenet/river#413's cap eviction.
298 let members_after_ban_enforcement: HashSet<MemberId> =
299 self.members.members.iter().map(|m| m.member.id()).collect();
300 let dm_participants: HashSet<MemberId> =
301 self.direct_messages.participants_of_surviving_dms(
302 owner_id,
303 &members_after_ban_enforcement,
304 &enforced_banned_ids,
305 );
306 let current_secret_version = self.secrets.current_version;
307 let secret_recipients: HashSet<MemberId> = self
308 .secrets
309 .encrypted_secrets
310 .iter()
311 .filter(|s| s.secret.secret_version == current_secret_version)
312 .map(|s| s.secret.member_id)
313 .collect();
314
315 // 2. Compute required members: authors + DM participants + secret
316 // recipients + their invite chains.
317 let required_ids = {
318 let members_by_id = self.members.members_by_member_id();
319 let mut required_ids: HashSet<MemberId> = HashSet::new();
320
321 for author_id in &message_authors {
322 if *author_id != owner_id && members_by_id.contains_key(author_id) {
323 required_ids.insert(*author_id);
324 }
325 }
326
327 for participant_id in &dm_participants {
328 if *participant_id != owner_id && members_by_id.contains_key(participant_id) {
329 required_ids.insert(*participant_id);
330 }
331 }
332
333 for recipient_id in &secret_recipients {
334 if *recipient_id != owner_id && members_by_id.contains_key(recipient_id) {
335 required_ids.insert(*recipient_id);
336 }
337 }
338
339 // A member who is the BANNER of a ban that will SURVIVE the step-5
340 // sweep is exempt from inactivity-prune (#411 round 3 item B).
341 // Otherwise an inactive moderator's bans would vanish (a banner pruned
342 // to non-member has their bans swept in step 5). Mirrors the
343 // `encrypted_secrets` exemption and is a pure function of converged
344 // state. `self.bans.0` was ALREADY capped to `max_user_bans` at step
345 // 0-cap (top of this function), so this loop iterates only the
346 // surviving bans: a banner whose ban the cap evicted is NOT exempted
347 // here, so it is not kept on pass 1 and then pruned on pass 2 (#411
348 // round 7 / Codex P1 #2 — the cap MUST precede this exemption).
349 // Runs BEFORE the invite-chain walk so the banner's ancestors are
350 // kept too (a kept member needs a valid chain). The owner can still
351 // explicitly ban an abusive banner — banning is separate from
352 // inactivity-prune, and a banned banner's bans are then swept in step 5.
353 //
354 // IDEMPOTENCE (#411 round 4/5): the exemption MUST use the SAME
355 // predicate as the step-5 sweep — `ban_signature_matches_current_key`,
356 // NOT a bare `contains_key`. Round 4 made the sweep drop a
357 // current-member-banner ban whose signature FAILS; if the exemption
358 // still kept the banner on a bare membership check, a content-free
359 // member P held solely by a garbage-sig ban Z would be KEPT on pass 1
360 // (exempted) while Z is swept, then PRUNED on pass 2 (no ban left) —
361 // so `cleanup(S) != cleanup(cleanup(S))`. Because Freenet runs
362 // post_apply_cleanup a variable number of times (and full-state PUTs
363 // bypass it via verify), that non-idempotence diverges the member set
364 // permanently. Gating on the sweep predicate makes exemption ⟺
365 // retention: a banner is exempted iff its ban actually survives. No
366 // circularity — a sig-matching banner is added to `required_ids`, so it
367 // survives step 3 to step 5, where the same predicate keeps its ban.
368 for ban in &self.bans.0 {
369 let banner = ban.banned_by;
370 if banner != owner_id
371 && BansV1::ban_signature_matches_current_key(
372 ban,
373 &members_by_id,
374 owner_id,
375 ¶meters.owner,
376 )
377 {
378 required_ids.insert(banner);
379 }
380 }
381
382 // Walk invite chains upward, adding all ancestors (stop at owner)
383 let mut to_process: Vec<MemberId> = required_ids.iter().cloned().collect();
384 while let Some(member_id) = to_process.pop() {
385 if let Some(member) = members_by_id.get(&member_id) {
386 let inviter_id = member.member.invited_by;
387 if inviter_id != owner_id && !required_ids.contains(&inviter_id) {
388 required_ids.insert(inviter_id);
389 to_process.push(inviter_id);
390 }
391 }
392 }
393
394 required_ids
395 };
396
397 // 3. Prune members not in required set
398 self.members
399 .members
400 .retain(|m| required_ids.contains(&m.member.id()));
401
402 // 4. Clean member_info for pruned members
403 self.member_info.member_info.retain(|info| {
404 info.member_info.member_id == owner_id
405 || required_ids.contains(&info.member_info.member_id)
406 });
407
408 // 4a. Collapse duplicate member_info records to the single canonical
409 // (highest-rank) one per member (#411 round 8 item C / security
410 // FINDING 2+3). `verify` accepts duplicate records (migration-safety),
411 // so a peer can hold several records for one member; without this,
412 // two peers with different duplicate SETS would diverge byte-for-byte
413 // forever (the raw `member_info` vectors differ even though every
414 // canonical read agrees). Dedup is a pure function of the converged
415 // state, so it is deterministic, idempotent, and order-independent,
416 // and bounds stored `member_info` to <= one record per member. Runs
417 // here (post_apply_cleanup), never in verify/validate_state, so the
418 // permissionless migration PUT is unaffected.
419 self.member_info.dedup_to_canonical();
420
421 // 4b. Sweep recent messages authored by members removed above (deputy-
422 // authorized ban cascade or inactivity prune).
423 // `MessagesV1::apply_delta` already drops non-member-authored
424 // messages, but it runs BEFORE this cleanup in field order — so a
425 // member removed HERE (a deputy-authorized ban, #410, is only
426 // enforceable once the converged member_info is available, which is
427 // after the recent_messages field has been applied) would otherwise
428 // leave orphaned messages that fail `MessagesV1::verify`
429 // ("Message author not found"). Owner-authored messages are always
430 // valid.
431 let current_member_ids: HashSet<MemberId> =
432 self.members.members.iter().map(|m| m.member.id()).collect();
433 self.recent_messages.messages.retain(|m| {
434 m.message.author == owner_id || current_member_ids.contains(&m.message.author)
435 });
436
437 // Rebuild the PUBLIC `actions_state` cache now that removed authors'
438 // messages are gone (#411 round 7 / Codex P2 #4). `MessagesV1::apply_delta`
439 // already rebuilt this cache, but it ran BEFORE the sweep above, so a
440 // deputy-banned member's edit/delete/reaction would linger in the cache
441 // (e.g. their reaction still rendered on a message). The UI's private
442 // rebuild (`rebuild_actions_state_with_decrypted`) is a no-op for a public
443 // room, so nothing else recomputes it. This is the same public-only rebuild
444 // `apply_delta` runs; the UI re-runs its private rebuild after apply.
445 self.recent_messages.rebuild_actions_state();
446
447 // 5. Sweep any ban that is not backed by a signature-verified authority
448 // (#411 round 3 item A.3 + round 4 item A). Nothing unvalidated stays
449 // in state (AGENTS.md State Authorization Rule). A ban is kept only if
450 // `ban_signature_matches_current_key` holds: the banner is the OWNER or
451 // a CURRENT member AND the stored signature verifies against that
452 // banner's CURRENT converged key. This drops two classes:
453 // (a) non-member banners — a stale/pruned deputy ID or forged banner,
454 // whose signature `verify` skipped and whom `is_ban_authorized`
455 // grants nothing (round 3); and
456 // (b) current-member banners whose signature does NOT match the
457 // converged key — the same-delta pruned-deputy REPLAY forgery,
458 // where a public `AuthorizedMember` was replayed to make the banner
459 // a member while `verify` skipped the garbage ban signature at
460 // apply time (round 4). Enforcement (step 0 / `banned_member_ids`)
461 // already refuses to act on such a ban; this sweeps it from state.
462 // Real member-banners with valid signatures were kept present by the
463 // item-B exemption above, so their bans survive. Runs against CONVERGED
464 // state, keeping `verify` stable (migration-safe). `members_by_id` is
465 // rebuilt here because the sweep needs each banner's `member_vk`.
466 let members_by_id_for_ban_sweep = self.members.members_by_member_id();
467 self.bans.0.retain(|ban| {
468 BansV1::ban_signature_matches_current_key(
469 ban,
470 &members_by_id_for_ban_sweep,
471 owner_id,
472 ¶meters.owner,
473 )
474 });
475
476 // (The `max_user_bans` cap runs at the TOP of this function now — step
477 // "0-cap" — so ban enforcement and the banner exemption read the final
478 // surviving ban set. This signature sweep only shrinks the set further,
479 // so the count stays <= the cap. See #411 round 7 / Codex P1 #1+#2.)
480
481 // 6. Sweep DMs whose participants are no longer current members
482 // or are ENFORCED-banned. Without this, a fresh ban (or member-prune)
483 // would leave the DMs in state but break `verify` because the
484 // sender/recipient can no longer be resolved.
485 //
486 // We use the enforced-ban set from step 0 rather than every ban
487 // target: a member whose ban is inert (e.g. a revoked deputy's ban,
488 // #410) is still a current member and their DMs must survive.
489 // Enforced-banned members were already removed above, so the
490 // active-member check alone would sweep them, but passing the set is
491 // harmless and keeps the intent explicit.
492 let active_member_ids_for_sweep: HashSet<MemberId> =
493 self.members.members.iter().map(|m| m.member.id()).collect();
494 // The step-1 DM exemption is only sound while steps 2-5 are
495 // removal-only for members: it counts a DM as surviving using the
496 // post-step-0 member set, and step 6 then re-tests the same DM
497 // against the post-step-3 set. If anything between them ADDED a
498 // member, step 6 could keep a DM step 1 did not count — harmless —
499 // but the reverse reasoning (exemption ⟹ retention) would no longer
500 // be a subset argument, and freenet/river#671 would be back. Nothing
501 // does today; `debug_assert!` states that where an editor of steps
502 // 2-5 will read it, and compiles out of the release WASM so the
503 // contract pays nothing. It runs across the whole existing
504 // `post_apply_cleanup`, convergence and deputy-ban suites.
505 debug_assert!(
506 active_member_ids_for_sweep.is_subset(&members_after_ban_enforcement),
507 "steps 2-5 must be removal-only for members: the step-1 DM exemption \
508 reads the post-step-0 set and step 6 the post-step-3 one"
509 );
510 self.direct_messages.sweep_after_membership_change(
511 owner_id,
512 &active_member_ids_for_sweep,
513 &enforced_banned_ids,
514 );
515
516 // 7. Re-sort for deterministic ordering
517 self.members.members.sort_by_key(|m| m.member.id());
518 self.member_info
519 .member_info
520 .sort_by_key(|info| info.member_info.member_id);
521
522 Ok(())
523 }
524}
525
526#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
527pub struct ChatRoomParametersV1 {
528 pub owner: VerifyingKey,
529}
530
531impl ChatRoomParametersV1 {
532 pub fn owner_id(&self) -> MemberId {
533 self.owner.into()
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::room_state::ban::{AuthorizedUserBan, UserBan};
541 use crate::room_state::configuration::Configuration;
542 use crate::room_state::member::{AuthorizedMember, Member};
543 use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
544 use crate::room_state::message::{AuthorizedMessageV1, MessageV1, RoomMessageBody};
545 use ed25519_dalek::SigningKey;
546 use std::fmt::Debug;
547 use std::time::SystemTime;
548
549 #[test]
550 fn test_state() {
551 let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
552
553 assert!(
554 state.verify(&state, ¶meters).is_ok(),
555 "Empty state should verify"
556 );
557
558 // Test that the configuration can be updated
559 let mut new_cfg = state.configuration.configuration.clone();
560 new_cfg.configuration_version += 1;
561 new_cfg.max_recent_messages = 10; // Change from default of 100 to 10
562 let new_cfg = AuthorizedConfigurationV1::new(new_cfg, &owner_signing_key);
563
564 let mut cfg_modified_state = state.clone();
565 cfg_modified_state.configuration = new_cfg;
566 test_apply_delta(state.clone(), cfg_modified_state, ¶meters);
567 }
568
569 fn test_apply_delta<CS>(orig_state: CS, modified_state: CS, parameters: &CS::Parameters)
570 where
571 CS: ComposableState<ParentState = CS> + Clone + PartialEq + Debug,
572 {
573 let orig_verify_result = orig_state.verify(&orig_state, parameters);
574 assert!(
575 orig_verify_result.is_ok(),
576 "Original state verification failed: {:?}",
577 orig_verify_result.err()
578 );
579
580 let modified_verify_result = modified_state.verify(&modified_state, parameters);
581 assert!(
582 modified_verify_result.is_ok(),
583 "Modified state verification failed: {:?}",
584 modified_verify_result.err()
585 );
586
587 let delta = modified_state.delta(
588 &orig_state,
589 parameters,
590 &orig_state.summarize(&orig_state, parameters),
591 );
592
593 println!("Delta: {:?}", delta);
594
595 let mut new_state = orig_state.clone();
596 let apply_delta_result = new_state.apply_delta(&orig_state, parameters, &delta);
597 assert!(
598 apply_delta_result.is_ok(),
599 "Applying delta failed: {:?}",
600 apply_delta_result.err()
601 );
602
603 assert_eq!(new_state, modified_state);
604 }
605 fn create_empty_chat_room_state() -> (ChatRoomStateV1, ChatRoomParametersV1, SigningKey) {
606 // Create a test room_state with a single member and two messages, one written by
607 // the owner and one by the member - the member must be invited by the owner
608 let rng = &mut rand::thread_rng();
609 let owner_signing_key = SigningKey::generate(rng);
610 let owner_verifying_key = owner_signing_key.verifying_key();
611
612 let config = AuthorizedConfigurationV1::new(Configuration::default(), &owner_signing_key);
613
614 (
615 ChatRoomStateV1 {
616 configuration: config,
617 bans: BansV1::default(),
618 members: MembersV1::default(),
619 member_info: MemberInfoV1::default(),
620 secrets: RoomSecretsV1::default(),
621 recent_messages: MessagesV1::default(),
622 upgrade: OptionalUpgradeV1(None),
623 ..Default::default()
624 },
625 ChatRoomParametersV1 {
626 owner: owner_verifying_key,
627 },
628 owner_signing_key,
629 )
630 }
631
632 /// Regression test: when a member who issued bans is subsequently banned themselves,
633 /// their bans become orphaned (banning member no longer in members list and not owner).
634 /// The post_apply_delta hook post_apply_cleanup must remove these to prevent verify() failure.
635 /// See: technic corrupted state incident (Feb 2026)
636 #[test]
637 fn test_orphaned_ban_cleanup_after_cascade_removal() {
638 let rng = &mut rand::thread_rng();
639
640 // Create owner
641 let owner_sk = SigningKey::generate(rng);
642 let owner_vk = owner_sk.verifying_key();
643 let owner_id = MemberId::from(&owner_vk);
644 let params = ChatRoomParametersV1 { owner: owner_vk };
645
646 // Configuration allowing bans and members
647 let config = Configuration {
648 max_user_bans: 10,
649 max_members: 10,
650 ..Default::default()
651 };
652 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
653
654 // Create member A (invited by owner) and member B (invited by A)
655 let a_sk = SigningKey::generate(rng);
656 let a_vk = a_sk.verifying_key();
657 let a_id = MemberId::from(&a_vk);
658
659 let b_sk = SigningKey::generate(rng);
660 let b_vk = b_sk.verifying_key();
661 let b_id = MemberId::from(&b_vk);
662
663 let member_a = AuthorizedMember::new(
664 Member {
665 owner_member_id: owner_id,
666 invited_by: owner_id,
667 member_vk: a_vk,
668 },
669 &owner_sk,
670 );
671
672 // A bans B (authorized because A is in B's invite chain)
673 let ban_b_by_a = AuthorizedUserBan::new(
674 UserBan {
675 owner_member_id: owner_id,
676 banned_at: std::time::SystemTime::now(),
677 banned_user: b_id,
678 },
679 a_id,
680 &a_sk,
681 );
682
683 // Initial state: A is a member, B already removed (ban took effect)
684 let initial_state = ChatRoomStateV1 {
685 configuration: auth_config.clone(),
686 bans: BansV1(vec![ban_b_by_a.clone()]),
687 members: MembersV1 {
688 members: vec![member_a.clone()],
689 },
690 ..Default::default()
691 };
692
693 assert!(
694 initial_state.verify(&initial_state, ¶ms).is_ok(),
695 "Initial state should verify: {:?}",
696 initial_state.verify(&initial_state, ¶ms)
697 );
698
699 // Now owner bans A — this will cascade-remove A from members,
700 // making A's ban of B orphaned (A is no longer in members and not owner)
701 let ban_a_by_owner = AuthorizedUserBan::new(
702 UserBan {
703 owner_member_id: owner_id,
704 banned_at: std::time::SystemTime::now() + std::time::Duration::from_secs(1),
705 banned_user: a_id,
706 },
707 owner_id,
708 &owner_sk,
709 );
710
711 // Modified state for delta computation: add owner's ban of A
712 let modified_for_delta = ChatRoomStateV1 {
713 configuration: auth_config,
714 bans: BansV1(vec![ban_b_by_a.clone(), ban_a_by_owner.clone()]),
715 members: MembersV1 {
716 members: vec![member_a.clone()],
717 },
718 ..Default::default()
719 };
720
721 // Compute and apply delta
722 let summary = initial_state.summarize(&initial_state, ¶ms);
723 let delta = modified_for_delta.delta(&initial_state, ¶ms, &summary);
724
725 let mut result_state = initial_state.clone();
726 let apply_result = result_state.apply_delta(&initial_state, ¶ms, &delta);
727 assert!(
728 apply_result.is_ok(),
729 "apply_delta should succeed: {:?}",
730 apply_result
731 );
732
733 // A should be removed (banned by owner)
734 assert!(
735 result_state.members.members.is_empty(),
736 "A should be removed from members: {:?}",
737 result_state.members.members
738 );
739
740 // Only owner's ban should remain — A's ban of B is orphaned and cleaned
741 assert_eq!(
742 result_state.bans.0.len(),
743 1,
744 "Only owner's ban should remain, orphaned ban cleaned: {:?}",
745 result_state.bans.0
746 );
747 assert_eq!(
748 result_state.bans.0[0].banned_by, owner_id,
749 "Remaining ban should be by owner"
750 );
751
752 // Result state should pass verification
753 assert!(
754 result_state.verify(&result_state, ¶ms).is_ok(),
755 "Result state should verify after orphaned ban cleanup: {:?}",
756 result_state.verify(&result_state, ¶ms)
757 );
758 }
759
760 #[test]
761 fn test_member_pruned_when_no_messages() {
762 let rng = &mut rand::thread_rng();
763 let owner_sk = SigningKey::generate(rng);
764 let owner_vk = owner_sk.verifying_key();
765 let owner_id = MemberId::from(&owner_vk);
766 let params = ChatRoomParametersV1 { owner: owner_vk };
767
768 let a_sk = SigningKey::generate(rng);
769 let a_vk = a_sk.verifying_key();
770 let a_id = MemberId::from(&a_vk);
771
772 let b_sk = SigningKey::generate(rng);
773 let b_vk = b_sk.verifying_key();
774
775 let member_a = AuthorizedMember::new(
776 Member {
777 owner_member_id: owner_id,
778 invited_by: owner_id,
779 member_vk: a_vk,
780 },
781 &owner_sk,
782 );
783 let member_b = AuthorizedMember::new(
784 Member {
785 owner_member_id: owner_id,
786 invited_by: owner_id,
787 member_vk: b_vk,
788 },
789 &owner_sk,
790 );
791
792 // Only A has a message
793 let msg_a = AuthorizedMessageV1::new(
794 MessageV1 {
795 room_owner: owner_id,
796 author: a_id,
797 time: SystemTime::now(),
798 content: RoomMessageBody::public("Hello from A".to_string()),
799 },
800 &a_sk,
801 );
802
803 let config = Configuration {
804 max_members: 10,
805 max_recent_messages: 100,
806 ..Default::default()
807 };
808 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
809
810 let mut state = ChatRoomStateV1 {
811 configuration: auth_config,
812 members: MembersV1 {
813 members: vec![member_a, member_b],
814 },
815 recent_messages: MessagesV1 {
816 messages: vec![msg_a],
817 ..Default::default()
818 },
819 ..Default::default()
820 };
821
822 state.post_apply_cleanup(¶ms).unwrap();
823
824 assert_eq!(state.members.members.len(), 1, "Only A should remain");
825 assert_eq!(state.members.members[0].member.id(), a_id);
826 }
827
828 #[test]
829 fn test_member_with_join_event_not_pruned() {
830 let rng = &mut rand::thread_rng();
831 let owner_sk = SigningKey::generate(rng);
832 let owner_vk = owner_sk.verifying_key();
833 let owner_id = MemberId::from(&owner_vk);
834 let params = ChatRoomParametersV1 { owner: owner_vk };
835
836 let a_sk = SigningKey::generate(rng);
837 let a_vk = a_sk.verifying_key();
838 let a_id = MemberId::from(&a_vk);
839
840 let member_a = AuthorizedMember::new(
841 Member {
842 owner_member_id: owner_id,
843 invited_by: owner_id,
844 member_vk: a_vk,
845 },
846 &owner_sk,
847 );
848
849 // A has only a join event (no regular messages)
850 let join_msg = AuthorizedMessageV1::new(
851 MessageV1 {
852 room_owner: owner_id,
853 author: a_id,
854 time: SystemTime::now(),
855 content: RoomMessageBody::join_event(),
856 },
857 &a_sk,
858 );
859
860 let config = Configuration {
861 max_members: 10,
862 max_recent_messages: 100,
863 ..Default::default()
864 };
865 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
866
867 let mut state = ChatRoomStateV1 {
868 configuration: auth_config,
869 members: MembersV1 {
870 members: vec![member_a],
871 },
872 recent_messages: MessagesV1 {
873 messages: vec![join_msg],
874 ..Default::default()
875 },
876 ..Default::default()
877 };
878
879 state.post_apply_cleanup(¶ms).unwrap();
880
881 assert_eq!(
882 state.members.members.len(),
883 1,
884 "Member with join event should not be pruned"
885 );
886 assert_eq!(state.members.members[0].member.id(), a_id);
887 }
888
889 /// Test that the atomic join delta (members + member_info + join event)
890 /// as produced by accept_invitation applies correctly and passes verify().
891 #[test]
892 fn test_atomic_join_delta_applies_and_verifies() {
893 use crate::room_state::member::MembersDelta;
894 use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
895 use crate::room_state::privacy::SealedBytes;
896
897 let rng = &mut rand::thread_rng();
898 let owner_sk = SigningKey::generate(rng);
899 let owner_vk = owner_sk.verifying_key();
900 let owner_id = MemberId::from(&owner_vk);
901 let params = ChatRoomParametersV1 { owner: owner_vk };
902
903 // Create a room with owner config
904 let config = Configuration {
905 owner_member_id: owner_id,
906 max_members: 10,
907 max_recent_messages: 100,
908 ..Default::default()
909 };
910 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
911 let mut state = ChatRoomStateV1 {
912 configuration: auth_config,
913 ..Default::default()
914 };
915
916 // New member accepts an invitation
917 let joiner_sk = SigningKey::generate(rng);
918 let joiner_vk = joiner_sk.verifying_key();
919 let joiner_id = MemberId::from(&joiner_vk);
920
921 let authorized_member = AuthorizedMember::new(
922 Member {
923 owner_member_id: owner_id,
924 invited_by: owner_id,
925 member_vk: joiner_vk,
926 },
927 &owner_sk,
928 );
929
930 let member_info = MemberInfo {
931 member_id: joiner_id,
932 version: 0,
933 preferred_nickname: SealedBytes::public("NewUser".to_string().into_bytes()),
934 deputies: Vec::new(),
935 };
936 let authorized_info = AuthorizedMemberInfo::new_with_member_key(member_info, &joiner_sk);
937
938 let join_message = AuthorizedMessageV1::new(
939 MessageV1 {
940 room_owner: owner_id,
941 author: joiner_id,
942 content: RoomMessageBody::join_event(),
943 time: SystemTime::now(),
944 },
945 &joiner_sk,
946 );
947
948 // Build the atomic delta (same as accept_invitation produces)
949 let delta = ChatRoomStateV1Delta {
950 recent_messages: Some(vec![join_message]),
951 members: Some(MembersDelta::new(vec![authorized_member])),
952 member_info: Some(vec![authorized_info]),
953 ..Default::default()
954 };
955
956 // Apply delta
957 let old_state = state.clone();
958 state
959 .apply_delta(&old_state, ¶ms, &Some(delta))
960 .expect("atomic join delta should apply cleanly");
961
962 // Verify state is valid
963 state
964 .verify(&state, ¶ms)
965 .expect("state should verify after join delta");
966
967 // Member should be present
968 assert!(
969 state
970 .members
971 .members
972 .iter()
973 .any(|m| m.member.id() == joiner_id),
974 "Joiner should be in members list"
975 );
976
977 // Member info should be present
978 assert!(
979 state
980 .member_info
981 .member_info
982 .iter()
983 .any(|i| i.member_info.member_id == joiner_id),
984 "Joiner should have member_info"
985 );
986
987 // Join event message should be present
988 assert_eq!(state.recent_messages.messages.len(), 1);
989 assert!(state.recent_messages.messages[0].message.content.is_event());
990
991 // Should survive post_apply_cleanup
992 state.post_apply_cleanup(¶ms).unwrap();
993 assert!(
994 state
995 .members
996 .members
997 .iter()
998 .any(|m| m.member.id() == joiner_id),
999 "Joiner should survive cleanup"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_invite_chain_preserved_for_active_member() {
1005 let rng = &mut rand::thread_rng();
1006 let owner_sk = SigningKey::generate(rng);
1007 let owner_vk = owner_sk.verifying_key();
1008 let owner_id = MemberId::from(&owner_vk);
1009 let params = ChatRoomParametersV1 { owner: owner_vk };
1010
1011 let a_sk = SigningKey::generate(rng);
1012 let a_vk = a_sk.verifying_key();
1013 let a_id = MemberId::from(&a_vk);
1014
1015 let b_sk = SigningKey::generate(rng);
1016 let b_vk = b_sk.verifying_key();
1017 let b_id = MemberId::from(&b_vk);
1018
1019 // Owner → A → B
1020 let member_a = AuthorizedMember::new(
1021 Member {
1022 owner_member_id: owner_id,
1023 invited_by: owner_id,
1024 member_vk: a_vk,
1025 },
1026 &owner_sk,
1027 );
1028 let member_b = AuthorizedMember::new(
1029 Member {
1030 owner_member_id: owner_id,
1031 invited_by: a_id,
1032 member_vk: b_vk,
1033 },
1034 &a_sk,
1035 );
1036
1037 // Only B has a message
1038 let msg_b = AuthorizedMessageV1::new(
1039 MessageV1 {
1040 room_owner: owner_id,
1041 author: b_id,
1042 time: SystemTime::now(),
1043 content: RoomMessageBody::public("Hello from B".to_string()),
1044 },
1045 &b_sk,
1046 );
1047
1048 let config = Configuration {
1049 max_members: 10,
1050 max_recent_messages: 100,
1051 ..Default::default()
1052 };
1053 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1054
1055 let mut state = ChatRoomStateV1 {
1056 configuration: auth_config,
1057 members: MembersV1 {
1058 members: vec![member_a, member_b],
1059 },
1060 recent_messages: MessagesV1 {
1061 messages: vec![msg_b],
1062 ..Default::default()
1063 },
1064 ..Default::default()
1065 };
1066
1067 state.post_apply_cleanup(¶ms).unwrap();
1068
1069 // Both A and B should remain (A is in B's invite chain)
1070 assert_eq!(state.members.members.len(), 2);
1071 let member_ids: HashSet<MemberId> = state
1072 .members
1073 .members
1074 .iter()
1075 .map(|m| m.member.id())
1076 .collect();
1077 assert!(
1078 member_ids.contains(&a_id),
1079 "A should be kept (in B's invite chain)"
1080 );
1081 assert!(
1082 member_ids.contains(&b_id),
1083 "B should be kept (has messages)"
1084 );
1085 }
1086
1087 /// #411 round 3 item B: a member who is the banner of a retained ban is
1088 /// EXEMPT from inactivity-pruning, so their ban does not vanish. (Before
1089 /// round 3 the banner was pruned and the ban persisted anyway; now the ban
1090 /// persists BECAUSE the banner is kept present, which is what keeps it valid
1091 /// under the round-3 "banner must be a current member" sweep.)
1092 #[test]
1093 fn test_banner_exempt_from_inactivity_prune_so_ban_persists() {
1094 let rng = &mut rand::thread_rng();
1095 let owner_sk = SigningKey::generate(rng);
1096 let owner_vk = owner_sk.verifying_key();
1097 let owner_id = MemberId::from(&owner_vk);
1098 let params = ChatRoomParametersV1 { owner: owner_vk };
1099
1100 let a_sk = SigningKey::generate(rng);
1101 let a_vk = a_sk.verifying_key();
1102 let a_id = MemberId::from(&a_vk);
1103
1104 let c_sk = SigningKey::generate(rng);
1105 let c_vk = c_sk.verifying_key();
1106 let c_id = MemberId::from(&c_vk);
1107
1108 // A is a member (invited by owner)
1109 let member_a = AuthorizedMember::new(
1110 Member {
1111 owner_member_id: owner_id,
1112 invited_by: owner_id,
1113 member_vk: a_vk,
1114 },
1115 &owner_sk,
1116 );
1117
1118 // A bans C
1119 let ban_c_by_a = AuthorizedUserBan::new(
1120 UserBan {
1121 owner_member_id: owner_id,
1122 banned_at: SystemTime::now(),
1123 banned_user: c_id,
1124 },
1125 a_id,
1126 &a_sk,
1127 );
1128
1129 let config = Configuration {
1130 max_members: 10,
1131 max_user_bans: 10,
1132 ..Default::default()
1133 };
1134 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1135
1136 // A has no messages, but A is the banner of a retained ban → exempt
1137 // from inactivity-prune (round 3 item B).
1138 let mut state = ChatRoomStateV1 {
1139 configuration: auth_config,
1140 members: MembersV1 {
1141 members: vec![member_a],
1142 },
1143 bans: BansV1(vec![ban_c_by_a]),
1144 ..Default::default()
1145 };
1146
1147 state.post_apply_cleanup(¶ms).unwrap();
1148
1149 // A is KEPT (exempt as a banner), not pruned.
1150 assert_eq!(
1151 state.members.members.len(),
1152 1,
1153 "A should be exempt from prune"
1154 );
1155 assert_eq!(state.members.members[0].member.id(), a_id);
1156
1157 // A's ban of C persists (banner A is still a current member).
1158 assert_eq!(state.bans.0.len(), 1, "Ban should persist");
1159 assert_eq!(state.bans.0[0].ban.banned_user, c_id);
1160 assert_eq!(state.bans.0[0].banned_by, a_id);
1161 }
1162
1163 /// #411 round 7 / Codex P1 #1: an over-cap ban that WILL be evicted by the
1164 /// `max_user_bans` cap must NOT one-shot-remove its target. If enforcement
1165 /// ran before the cap (the bug), the evicted ban would still have removed a
1166 /// member the capped converged state cannot reproduce — so a peer that only
1167 /// ever sees the capped state keeps that member and the two diverge.
1168 #[test]
1169 fn over_cap_ban_does_not_one_shot_remove() {
1170 let rng = &mut rand::thread_rng();
1171 let owner_sk = SigningKey::generate(rng);
1172 let owner_vk = owner_sk.verifying_key();
1173 let owner_id = MemberId::from(&owner_vk);
1174 let params = ChatRoomParametersV1 { owner: owner_vk };
1175
1176 // Only ONE ban may survive.
1177 let config = Configuration {
1178 max_members: 10,
1179 max_user_bans: 1,
1180 max_recent_messages: 100,
1181 ..Default::default()
1182 };
1183 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1184
1185 // Present member T with a message, so T is retained unless banned.
1186 let t_sk = SigningKey::generate(rng);
1187 let t_vk = t_sk.verifying_key();
1188 let t_id = MemberId::from(&t_vk);
1189 let member_t = AuthorizedMember::new(
1190 Member {
1191 owner_member_id: owner_id,
1192 invited_by: owner_id,
1193 member_vk: t_vk,
1194 },
1195 &owner_sk,
1196 );
1197 let msg_t = AuthorizedMessageV1::new(
1198 MessageV1 {
1199 room_owner: owner_id,
1200 author: t_id,
1201 time: SystemTime::now(),
1202 content: RoomMessageBody::public("hi".to_string()),
1203 },
1204 &t_sk,
1205 );
1206
1207 let base = SystemTime::now();
1208 // Ban of the PRESENT member T, OLDEST → evicted by the cap.
1209 let ban_t = AuthorizedUserBan::new(
1210 UserBan {
1211 owner_member_id: owner_id,
1212 banned_at: base,
1213 banned_user: t_id,
1214 },
1215 owner_id,
1216 &owner_sk,
1217 );
1218 // Ban of an ABSENT user, NEWER → survives the cap.
1219 let absent = MemberId::from(&SigningKey::generate(rng).verifying_key());
1220 let ban_absent = AuthorizedUserBan::new(
1221 UserBan {
1222 owner_member_id: owner_id,
1223 banned_at: base + std::time::Duration::from_secs(10),
1224 banned_user: absent,
1225 },
1226 owner_id,
1227 &owner_sk,
1228 );
1229
1230 let mut state = ChatRoomStateV1 {
1231 configuration: auth_config,
1232 members: MembersV1 {
1233 members: vec![member_t],
1234 },
1235 recent_messages: MessagesV1 {
1236 messages: vec![msg_t],
1237 ..Default::default()
1238 },
1239 bans: BansV1(vec![ban_t, ban_absent]),
1240 ..Default::default()
1241 };
1242
1243 state.post_apply_cleanup(¶ms).unwrap();
1244
1245 assert!(
1246 state.members.members.iter().any(|m| m.member.id() == t_id),
1247 "an over-cap ban that gets evicted must NOT one-shot-remove its target"
1248 );
1249 assert_eq!(state.bans.0.len(), 1, "capped to max_user_bans");
1250 assert!(
1251 state.bans.0.iter().any(|b| b.ban.banned_user == absent),
1252 "the surviving ban is the newer absent-target one"
1253 );
1254 }
1255
1256 /// #411 round 7 / Codex P1 #2: a banner whose ban the `max_user_bans` cap
1257 /// evicts must lose their prune exemption on the SAME pass. If the cap ran
1258 /// AFTER the exemption (the bug), the banner is kept on pass 1 and pruned on
1259 /// pass 2 — `cleanup(S) != cleanup(cleanup(S))` — permanently diverging peers
1260 /// that run cleanup a different number of times.
1261 #[test]
1262 fn cleanup_is_idempotent_over_cap_evicted_ban() {
1263 let rng = &mut rand::thread_rng();
1264 let owner_sk = SigningKey::generate(rng);
1265 let owner_vk = owner_sk.verifying_key();
1266 let owner_id = MemberId::from(&owner_vk);
1267 let params = ChatRoomParametersV1 { owner: owner_vk };
1268
1269 let config = Configuration {
1270 max_members: 10,
1271 max_user_bans: 2,
1272 max_recent_messages: 100,
1273 ..Default::default()
1274 };
1275 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1276
1277 // Member B: a current member with NO message/DM/secret — only being a
1278 // retained banner could keep them from the inactivity prune.
1279 let b_sk = SigningKey::generate(rng);
1280 let b_vk = b_sk.verifying_key();
1281 let b_id = MemberId::from(&b_vk);
1282 let member_b = AuthorizedMember::new(
1283 Member {
1284 owner_member_id: owner_id,
1285 invited_by: owner_id,
1286 member_vk: b_vk,
1287 },
1288 &owner_sk,
1289 );
1290
1291 let base = SystemTime::now();
1292 // Two owner bans against ABSENT targets, NEWER → survive the cap.
1293 let absent1 = MemberId::from(&SigningKey::generate(rng).verifying_key());
1294 let absent2 = MemberId::from(&SigningKey::generate(rng).verifying_key());
1295 let owner_ban1 = AuthorizedUserBan::new(
1296 UserBan {
1297 owner_member_id: owner_id,
1298 banned_at: base + std::time::Duration::from_secs(10),
1299 banned_user: absent1,
1300 },
1301 owner_id,
1302 &owner_sk,
1303 );
1304 let owner_ban2 = AuthorizedUserBan::new(
1305 UserBan {
1306 owner_member_id: owner_id,
1307 banned_at: base + std::time::Duration::from_secs(11),
1308 banned_user: absent2,
1309 },
1310 owner_id,
1311 &owner_sk,
1312 );
1313 // B's ban against an ABSENT target, OLDEST → evicted by the cap. It is
1314 // "enforcing" (member banner, absent target) so ONLY the cap removes it.
1315 let c_id = MemberId::from(&SigningKey::generate(rng).verifying_key());
1316 let ban_c_by_b = AuthorizedUserBan::new(
1317 UserBan {
1318 owner_member_id: owner_id,
1319 banned_at: base,
1320 banned_user: c_id,
1321 },
1322 b_id,
1323 &b_sk,
1324 );
1325
1326 let state = ChatRoomStateV1 {
1327 configuration: auth_config,
1328 members: MembersV1 {
1329 members: vec![member_b],
1330 },
1331 bans: BansV1(vec![ban_c_by_b, owner_ban1, owner_ban2]),
1332 ..Default::default()
1333 };
1334
1335 let mut once = state.clone();
1336 once.post_apply_cleanup(¶ms).unwrap();
1337
1338 // B's ban is cap-evicted, so B is NOT exempted and is pruned on pass 1.
1339 assert!(
1340 !once.members.members.iter().any(|m| m.member.id() == b_id),
1341 "over-cap-evicted banner B must be pruned on the FIRST cleanup pass"
1342 );
1343 assert_eq!(once.bans.0.len(), 2, "capped to max_user_bans");
1344
1345 // Idempotence: a second pass changes nothing.
1346 let mut twice = once.clone();
1347 twice.post_apply_cleanup(¶ms).unwrap();
1348 assert_eq!(once, twice, "post_apply_cleanup must be idempotent");
1349 }
1350
1351 /// #411 round 7 / Codex P2 #4: after a deputy-banned member's messages are
1352 /// swept in cleanup, the PUBLIC `actions_state` cache must be rebuilt so
1353 /// their reaction no longer lingers (the UI's private rebuild is a no-op for
1354 /// a public room).
1355 #[test]
1356 fn banned_member_reaction_removed_from_actions_state_cache() {
1357 let rng = &mut rand::thread_rng();
1358 let owner_sk = SigningKey::generate(rng);
1359 let owner_vk = owner_sk.verifying_key();
1360 let owner_id = MemberId::from(&owner_vk);
1361 let params = ChatRoomParametersV1 { owner: owner_vk };
1362
1363 let config = Configuration {
1364 max_members: 10,
1365 max_user_bans: 10,
1366 max_recent_messages: 100,
1367 ..Default::default()
1368 };
1369 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1370
1371 // M authors a message; R reacts to it. Both are members.
1372 let m_sk = SigningKey::generate(rng);
1373 let m_vk = m_sk.verifying_key();
1374 let m_id = MemberId::from(&m_vk);
1375 let r_sk = SigningKey::generate(rng);
1376 let r_vk = r_sk.verifying_key();
1377 let r_id = MemberId::from(&r_vk);
1378 let member_m = AuthorizedMember::new(
1379 Member {
1380 owner_member_id: owner_id,
1381 invited_by: owner_id,
1382 member_vk: m_vk,
1383 },
1384 &owner_sk,
1385 );
1386 let member_r = AuthorizedMember::new(
1387 Member {
1388 owner_member_id: owner_id,
1389 invited_by: owner_id,
1390 member_vk: r_vk,
1391 },
1392 &owner_sk,
1393 );
1394
1395 let msg1 = AuthorizedMessageV1::new(
1396 MessageV1 {
1397 room_owner: owner_id,
1398 author: m_id,
1399 time: SystemTime::now(),
1400 content: RoomMessageBody::public("hello".to_string()),
1401 },
1402 &m_sk,
1403 );
1404 let msg1_id = msg1.id();
1405 let reaction = AuthorizedMessageV1::new(
1406 MessageV1 {
1407 room_owner: owner_id,
1408 author: r_id,
1409 time: SystemTime::now() + std::time::Duration::from_secs(1),
1410 content: RoomMessageBody::reaction(msg1_id.clone(), "👍".to_string()),
1411 },
1412 &r_sk,
1413 );
1414
1415 let mut state = ChatRoomStateV1 {
1416 configuration: auth_config,
1417 members: MembersV1 {
1418 members: vec![member_m, member_r],
1419 },
1420 recent_messages: MessagesV1 {
1421 messages: vec![msg1.clone(), reaction],
1422 ..Default::default()
1423 },
1424 ..Default::default()
1425 };
1426
1427 // Populate the actions_state cache as `apply_delta` would, WITH R present.
1428 state.recent_messages.rebuild_actions_state();
1429 assert!(
1430 state
1431 .recent_messages
1432 .reactions(&msg1_id)
1433 .and_then(|r| r.get("👍"))
1434 .is_some_and(|v| v.contains(&r_id)),
1435 "sanity: R's reaction is present in the cache before the ban"
1436 );
1437
1438 // Owner bans R; cleanup removes R + their reaction message.
1439 state.bans.0.push(AuthorizedUserBan::new(
1440 UserBan {
1441 owner_member_id: owner_id,
1442 banned_at: SystemTime::now(),
1443 banned_user: r_id,
1444 },
1445 owner_id,
1446 &owner_sk,
1447 ));
1448
1449 state.post_apply_cleanup(¶ms).unwrap();
1450
1451 assert!(
1452 !state.members.members.iter().any(|m| m.member.id() == r_id),
1453 "R is banned and removed"
1454 );
1455 let lingers = state
1456 .recent_messages
1457 .reactions(&msg1_id)
1458 .and_then(|r| r.get("👍"))
1459 .is_some_and(|v| v.contains(&r_id));
1460 assert!(
1461 !lingers,
1462 "the banned member's reaction must be gone from the rebuilt public \
1463 actions_state cache"
1464 );
1465 }
1466
1467 /// #411 round 7: `post_apply_cleanup` must be idempotent on adversarial
1468 /// states (running it twice yields the same state), otherwise peers that run
1469 /// cleanup a different number of times diverge.
1470 #[test]
1471 fn post_apply_cleanup_is_idempotent_on_adversarial_states() {
1472 let rng = &mut rand::thread_rng();
1473 let owner_sk = SigningKey::generate(rng);
1474 let owner_vk = owner_sk.verifying_key();
1475 let owner_id = MemberId::from(&owner_vk);
1476 let params = ChatRoomParametersV1 { owner: owner_vk };
1477
1478 let cfg = |max_bans: usize| {
1479 AuthorizedConfigurationV1::new(
1480 Configuration {
1481 max_members: 50,
1482 max_user_bans: max_bans,
1483 max_recent_messages: 100,
1484 ..Default::default()
1485 },
1486 &owner_sk,
1487 )
1488 };
1489 let owner_member = |vk| {
1490 AuthorizedMember::new(
1491 Member {
1492 owner_member_id: owner_id,
1493 invited_by: owner_id,
1494 member_vk: vk,
1495 },
1496 &owner_sk,
1497 )
1498 };
1499 let assert_idem = |state: &ChatRoomStateV1, label: &str| {
1500 let mut once = state.clone();
1501 once.post_apply_cleanup(¶ms).unwrap();
1502 let mut twice = once.clone();
1503 twice.post_apply_cleanup(¶ms).unwrap();
1504 assert_eq!(once, twice, "post_apply_cleanup not idempotent: {}", label);
1505 };
1506
1507 // State A: five member-banners of ABSENT targets, over a cap of 2. Three
1508 // are cap-evicted (losing their exemption); two survive.
1509 {
1510 let banners: Vec<SigningKey> = (0..5).map(|_| SigningKey::generate(rng)).collect();
1511 let members: Vec<AuthorizedMember> = banners
1512 .iter()
1513 .map(|sk| owner_member(sk.verifying_key()))
1514 .collect();
1515 let base = SystemTime::now();
1516 let bans: Vec<AuthorizedUserBan> = banners
1517 .iter()
1518 .enumerate()
1519 .map(|(i, sk)| {
1520 let absent = MemberId::from(&SigningKey::generate(rng).verifying_key());
1521 AuthorizedUserBan::new(
1522 UserBan {
1523 owner_member_id: owner_id,
1524 banned_at: base + std::time::Duration::from_secs(i as u64),
1525 banned_user: absent,
1526 },
1527 MemberId::from(&sk.verifying_key()),
1528 sk,
1529 )
1530 })
1531 .collect();
1532 let state = ChatRoomStateV1 {
1533 configuration: cfg(2),
1534 members: MembersV1 { members },
1535 bans: BansV1(bans),
1536 ..Default::default()
1537 };
1538 assert_idem(&state, "member-banners over cap");
1539 }
1540
1541 // State B: owner cascade-bans A (who had banned B), plus an over-cap
1542 // flood of inert member bans against a present member X.
1543 {
1544 let a_sk = SigningKey::generate(rng);
1545 let a_id = MemberId::from(&a_sk.verifying_key());
1546 let b_id = MemberId::from(&SigningKey::generate(rng).verifying_key());
1547 let x_sk = SigningKey::generate(rng);
1548 let x_id = MemberId::from(&x_sk.verifying_key());
1549 let flood_banners: Vec<SigningKey> =
1550 (0..4).map(|_| SigningKey::generate(rng)).collect();
1551
1552 let mut members = vec![
1553 owner_member(a_sk.verifying_key()),
1554 owner_member(x_sk.verifying_key()),
1555 ];
1556 for sk in &flood_banners {
1557 members.push(owner_member(sk.verifying_key()));
1558 }
1559
1560 let base = SystemTime::now();
1561 let mut bans = vec![
1562 // A banned B (A is a member banner; B absent).
1563 AuthorizedUserBan::new(
1564 UserBan {
1565 owner_member_id: owner_id,
1566 banned_at: base,
1567 banned_user: b_id,
1568 },
1569 a_id,
1570 &a_sk,
1571 ),
1572 // Owner bans A (cascade removes A → A's ban of B is orphaned).
1573 AuthorizedUserBan::new(
1574 UserBan {
1575 owner_member_id: owner_id,
1576 banned_at: base + std::time::Duration::from_secs(1),
1577 banned_user: a_id,
1578 },
1579 owner_id,
1580 &owner_sk,
1581 ),
1582 ];
1583 // Inert flood: each floods a ban of present member X (no authority).
1584 for (i, sk) in flood_banners.iter().enumerate() {
1585 bans.push(AuthorizedUserBan::new(
1586 UserBan {
1587 owner_member_id: owner_id,
1588 banned_at: base + std::time::Duration::from_secs(100 + i as u64),
1589 banned_user: x_id,
1590 },
1591 MemberId::from(&sk.verifying_key()),
1592 sk,
1593 ));
1594 }
1595
1596 let state = ChatRoomStateV1 {
1597 configuration: cfg(3),
1598 members: MembersV1 { members },
1599 bans: BansV1(bans),
1600 ..Default::default()
1601 };
1602 assert_idem(&state, "owner cascade + inert flood over cap");
1603 }
1604
1605 // State C: a member held alive ONLY by a DM with a banned counterparty.
1606 // The step-1 exemption must not count it (freenet/river#671) — if it
1607 // does, M is kept on pass 1 and pruned on pass 2 by step 6's sweep.
1608 {
1609 let m_sk = SigningKey::generate(rng);
1610 let q_sk = SigningKey::generate(rng);
1611 let m_id = MemberId::from(&m_sk.verifying_key());
1612 let q_id = MemberId::from(&q_sk.verifying_key());
1613 let state = ChatRoomStateV1 {
1614 configuration: cfg(10),
1615 members: MembersV1 {
1616 members: vec![
1617 owner_member(m_sk.verifying_key()),
1618 owner_member(q_sk.verifying_key()),
1619 ],
1620 },
1621 bans: BansV1(vec![AuthorizedUserBan::new(
1622 UserBan {
1623 owner_member_id: owner_id,
1624 banned_at: SystemTime::now(),
1625 banned_user: q_id,
1626 },
1627 owner_id,
1628 &owner_sk,
1629 )]),
1630 direct_messages: crate::room_state::direct_messages::DirectMessagesV1 {
1631 messages: vec![crate::room_state::direct_messages::sign_direct_message(
1632 &m_sk,
1633 m_id,
1634 q_id,
1635 &owner_vk,
1636 1_700_000_000,
1637 vec![7u8; 8],
1638 )
1639 .expect("sign dm")],
1640 purges: vec![],
1641 },
1642 ..Default::default()
1643 };
1644 assert_idem(
1645 &state,
1646 "member held only by a DM with a banned counterparty",
1647 );
1648 }
1649
1650 // State D: the same shape, but the counterparty is banned by a DEPUTY
1651 // rather than the owner. `post_apply_cleanup` sees the converged
1652 // `member_info`, so step 0 DOES enforce this ban — the deputy gap is in
1653 // `MembersV1::apply_delta`, not here (freenet/river#675). Cleanup must
1654 // still be a fixpoint.
1655 {
1656 let dep_sk = SigningKey::generate(rng);
1657 let m_sk = SigningKey::generate(rng);
1658 let q_sk = SigningKey::generate(rng);
1659 let dep_id = MemberId::from(&dep_sk.verifying_key());
1660 let m_id = MemberId::from(&m_sk.verifying_key());
1661 let q_id = MemberId::from(&q_sk.verifying_key());
1662 let mut owner_info = MemberInfo::new_public(owner_id, 1, "owner".to_string());
1663 owner_info.deputies = vec![dep_id];
1664 let state = ChatRoomStateV1 {
1665 configuration: cfg(10),
1666 members: MembersV1 {
1667 members: vec![
1668 owner_member(dep_sk.verifying_key()),
1669 owner_member(m_sk.verifying_key()),
1670 owner_member(q_sk.verifying_key()),
1671 ],
1672 },
1673 member_info: MemberInfoV1 {
1674 member_info: vec![AuthorizedMemberInfo::new(owner_info, &owner_sk)],
1675 },
1676 bans: BansV1(vec![AuthorizedUserBan::new(
1677 UserBan {
1678 owner_member_id: owner_id,
1679 banned_at: SystemTime::now(),
1680 banned_user: q_id,
1681 },
1682 dep_id,
1683 &dep_sk,
1684 )]),
1685 direct_messages: crate::room_state::direct_messages::DirectMessagesV1 {
1686 messages: vec![crate::room_state::direct_messages::sign_direct_message(
1687 &m_sk,
1688 m_id,
1689 q_id,
1690 &owner_vk,
1691 1_700_000_100,
1692 vec![9u8; 8],
1693 )
1694 .expect("sign dm")],
1695 purges: vec![],
1696 },
1697 ..Default::default()
1698 };
1699 assert_idem(
1700 &state,
1701 "member held only by a DM with a DEPUTY-banned counterparty",
1702 );
1703 }
1704 }
1705
1706 /// `direct_messages` MUST be declared after `members`.
1707 ///
1708 /// The whole placement argument for the #671 apply-time sweep rests on
1709 /// `parent_state.members` being the UPDATED member set when the DM field's
1710 /// `apply_delta` runs, and that holds only because the `#[composable]`
1711 /// macro applies fields in DECLARATION order. Serde emits struct fields in
1712 /// declaration order and ciborium writes them as a map in that order, so
1713 /// the serialized key order is a faithful proxy for it.
1714 ///
1715 /// Reorder the two and the DM sweep would test its held set against the
1716 /// PRE-update member set — silently restoring the data loss.
1717 ///
1718 /// THIS ASSERTS A PROXY, and that is worth naming given what this PR is
1719 /// about. The property that actually matters is the order in which
1720 /// `#[composable]` emits the per-field `apply_delta` calls; what this test
1721 /// reads is serialized CBOR key order. The proxy holds today because serde
1722 /// emits fields in declaration order, ciborium preserves it, and the macro
1723 /// derives its order from the same declaration — but it is one step
1724 /// removed. **A macro change that sorted or reordered field application
1725 /// would leave this test green while the invariant it guards broke.** This
1726 /// whole fix exists because a guard resembled the thing it guarded instead
1727 /// of being it; this one has a milder version of that shape, so the gap is
1728 /// stated rather than left for a reader to assume away.
1729 ///
1730 /// Coincidental coverage exists but is SHRINKING, which is the argument for
1731 /// keeping this pin rather than against it. The field-reorder mutation
1732 /// currently fails three tests: this one,
1733 /// `pruned_sender_can_dm_when_bundling_rejoin_delta` (the #291 rejoin pin),
1734 /// and `whole_state_merge_is_associative_for_the_671_shape`.
1735 ///
1736 /// It was FOUR before the #675 remedy.
1737 /// `global_cap_must_not_evict_legitimate_dms_for_doomed_ones` stopped
1738 /// detecting the reorder, and the reason is worth recording rather than
1739 /// treating as loss: its doomed DMs have an OWNER-banned endpoint, and the
1740 /// now-ban-aware apply-time sweep removes those on the ban axis whether or
1741 /// not `parent_state.members` is the updated set. The remedy made that
1742 /// test robust to field order, so it stopped being a witness to it. The
1743 /// member axis still depends on declaration order — which is why the other
1744 /// two still fire, and why this pin is not redundant.
1745 ///
1746 /// That is exactly the drift this pin exists for: every other detector is
1747 /// incidental to some particular DM shape a future author may legitimately
1748 /// rework, and one of them already has. This is the only one whose failure
1749 /// message names the reason and the issue number.
1750 #[test]
1751 fn field_declaration_order_puts_members_before_direct_messages() {
1752 let mut bytes = Vec::new();
1753 ciborium::ser::into_writer(&ChatRoomStateV1::default(), &mut bytes)
1754 .expect("default state serializes");
1755 let value: ciborium::value::Value =
1756 ciborium::de::from_reader(&bytes[..]).expect("round-trips as a CBOR value");
1757 let map = value
1758 .as_map()
1759 .expect("ChatRoomStateV1 serializes as a CBOR map");
1760 let index_of = |name: &str| {
1761 map.iter()
1762 .position(|(k, _)| k.as_text() == Some(name))
1763 .unwrap_or_else(|| panic!("field `{name}` missing from the serialized state"))
1764 };
1765 assert!(
1766 index_of("members") < index_of("direct_messages"),
1767 "`direct_messages` must be declared AFTER `members`: the #[composable] macro \
1768 applies fields in declaration order, and DirectMessagesV1::apply_delta sweeps \
1769 its held set against parent_state.members (freenet/river#671)"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_member_re_added_with_message() {
1775 let rng = &mut rand::thread_rng();
1776 let owner_sk = SigningKey::generate(rng);
1777 let owner_vk = owner_sk.verifying_key();
1778 let owner_id = MemberId::from(&owner_vk);
1779 let params = ChatRoomParametersV1 { owner: owner_vk };
1780
1781 let a_sk = SigningKey::generate(rng);
1782 let a_vk = a_sk.verifying_key();
1783 let a_id = MemberId::from(&a_vk);
1784
1785 let member_a = AuthorizedMember::new(
1786 Member {
1787 owner_member_id: owner_id,
1788 invited_by: owner_id,
1789 member_vk: a_vk,
1790 },
1791 &owner_sk,
1792 );
1793
1794 let config = Configuration {
1795 max_members: 10,
1796 max_recent_messages: 100,
1797 ..Default::default()
1798 };
1799 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1800
1801 // State with A but no messages
1802 let mut state = ChatRoomStateV1 {
1803 configuration: auth_config,
1804 members: MembersV1 {
1805 members: vec![member_a.clone()],
1806 },
1807 ..Default::default()
1808 };
1809
1810 // Cleanup prunes A
1811 state.post_apply_cleanup(¶ms).unwrap();
1812 assert!(state.members.members.is_empty(), "A should be pruned");
1813
1814 // Re-add A with a message
1815 state.members.members.push(member_a);
1816 let msg = AuthorizedMessageV1::new(
1817 MessageV1 {
1818 room_owner: owner_id,
1819 author: a_id,
1820 time: SystemTime::now(),
1821 content: RoomMessageBody::public("Hello again!".to_string()),
1822 },
1823 &a_sk,
1824 );
1825 state.recent_messages.messages.push(msg);
1826
1827 // Cleanup should keep A now
1828 state.post_apply_cleanup(¶ms).unwrap();
1829 assert_eq!(state.members.members.len(), 1, "A should be kept");
1830 assert_eq!(state.members.members[0].member.id(), a_id);
1831 }
1832
1833 #[test]
1834 fn test_member_info_cleaned_after_pruning() {
1835 let rng = &mut rand::thread_rng();
1836 let owner_sk = SigningKey::generate(rng);
1837 let owner_vk = owner_sk.verifying_key();
1838 let owner_id = MemberId::from(&owner_vk);
1839 let params = ChatRoomParametersV1 { owner: owner_vk };
1840
1841 let a_sk = SigningKey::generate(rng);
1842 let a_vk = a_sk.verifying_key();
1843 let a_id = MemberId::from(&a_vk);
1844
1845 let member_a = AuthorizedMember::new(
1846 Member {
1847 owner_member_id: owner_id,
1848 invited_by: owner_id,
1849 member_vk: a_vk,
1850 },
1851 &owner_sk,
1852 );
1853
1854 // Create member_info for A and owner
1855 let a_info = AuthorizedMemberInfo::new_with_member_key(
1856 MemberInfo::new_public(a_id, 1, "Alice".to_string()),
1857 &a_sk,
1858 );
1859 let owner_info = AuthorizedMemberInfo::new(
1860 MemberInfo::new_public(owner_id, 1, "Owner".to_string()),
1861 &owner_sk,
1862 );
1863
1864 let config = Configuration {
1865 max_members: 10,
1866 ..Default::default()
1867 };
1868 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1869
1870 let mut state = ChatRoomStateV1 {
1871 configuration: auth_config,
1872 members: MembersV1 {
1873 members: vec![member_a],
1874 },
1875 member_info: MemberInfoV1 {
1876 member_info: vec![owner_info, a_info],
1877 },
1878 ..Default::default()
1879 };
1880
1881 // A has no messages → gets pruned along with their member_info
1882 state.post_apply_cleanup(¶ms).unwrap();
1883
1884 assert!(state.members.members.is_empty(), "A should be pruned");
1885 assert_eq!(
1886 state.member_info.member_info.len(),
1887 1,
1888 "Only owner's info should remain"
1889 );
1890 assert_eq!(
1891 state.member_info.member_info[0].member_info.member_id, owner_id,
1892 "Remaining info should be owner's"
1893 );
1894 }
1895
1896 /// Regression test for issue #110 / Bug #3 PR B:
1897 ///
1898 /// A member with an `encrypted_secrets` entry (i.e. the owner has
1899 /// issued them a per-version room-secret blob) must survive
1900 /// `post_apply_cleanup` even if they have not yet authored any
1901 /// messages and have no active DMs. The owner-issued blob is proof
1902 /// that the owner considers them a member, and pruning them on the
1903 /// invitee's first state ingestion is the underlying cause of the
1904 /// "DM to inactive member fails" / "newly-invited member silently
1905 /// pruned" symptom Ivvor reported in Bug #3.
1906 #[test]
1907 fn test_member_with_encrypted_secret_survives_cleanup() {
1908 let rng = &mut rand::thread_rng();
1909 let owner_sk = SigningKey::generate(rng);
1910 let owner_vk = owner_sk.verifying_key();
1911 let owner_id = MemberId::from(&owner_vk);
1912 let params = ChatRoomParametersV1 { owner: owner_vk };
1913
1914 let a_sk = SigningKey::generate(rng);
1915 let a_vk = a_sk.verifying_key();
1916 let a_id = MemberId::from(&a_vk);
1917
1918 let member_a = AuthorizedMember::new(
1919 Member {
1920 owner_member_id: owner_id,
1921 invited_by: owner_id,
1922 member_vk: a_vk,
1923 },
1924 &owner_sk,
1925 );
1926
1927 // A has NO messages and NO DMs — under the pre-fix rules they
1928 // would be pruned by post_apply_cleanup. The owner-issued
1929 // encrypted secret is the only evidence of membership.
1930 let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1931 member_id: a_id,
1932 secret_version: 0,
1933 ciphertext: vec![0u8; 16], // dummy ciphertext — signature is what counts
1934 nonce: [0u8; 12],
1935 sender_ephemeral_public_key: [0u8; 32],
1936 provider: owner_id,
1937 };
1938 let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1939 secret_for_a,
1940 &owner_sk,
1941 );
1942
1943 let config = Configuration {
1944 max_members: 10,
1945 max_recent_messages: 100,
1946 ..Default::default()
1947 };
1948 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1949
1950 let mut state = ChatRoomStateV1 {
1951 configuration: auth_config,
1952 members: MembersV1 {
1953 members: vec![member_a],
1954 },
1955 secrets: crate::room_state::secret::RoomSecretsV1 {
1956 current_version: 0,
1957 versions: vec![],
1958 encrypted_secrets: vec![authorized_secret],
1959 },
1960 ..Default::default()
1961 };
1962
1963 state.post_apply_cleanup(¶ms).unwrap();
1964
1965 assert_eq!(
1966 state.members.members.len(),
1967 1,
1968 "A should survive cleanup because they have an encrypted_secrets entry"
1969 );
1970 assert_eq!(state.members.members[0].member.id(), a_id);
1971 }
1972
1973 /// IMPORTANT #4 (PR #272 review round 2): a member who is BOTH
1974 /// banned AND has a stale `encrypted_secrets` blob must still be
1975 /// pruned by `post_apply_cleanup`. The exemption introduced for
1976 /// issue #110 grants survival on the strength of the owner's
1977 /// blob, but bans must override — a ban is the owner's later,
1978 /// authoritative statement that this member is no longer trusted.
1979 ///
1980 /// The `members_by_id.contains_key(recipient_id)` guard at the
1981 /// cleanup site keeps this safe: the ban delta runs through the
1982 /// member-prune path before `post_apply_cleanup`'s `required_ids`
1983 /// collection, so by the time we check the exemption set, the
1984 /// banned member is no longer in `members_by_id` and the
1985 /// exemption clause is short-circuited. This test pins that
1986 /// behaviour against any future regression that loosens the
1987 /// guard.
1988 #[test]
1989 fn test_banned_member_with_encrypted_secret_is_still_pruned() {
1990 use crate::room_state::ban::{AuthorizedUserBan, UserBan};
1991 use std::time::SystemTime;
1992
1993 let rng = &mut rand::thread_rng();
1994 let owner_sk = SigningKey::generate(rng);
1995 let owner_vk = owner_sk.verifying_key();
1996 let owner_id = MemberId::from(&owner_vk);
1997 let params = ChatRoomParametersV1 { owner: owner_vk };
1998
1999 let a_sk = SigningKey::generate(rng);
2000 let a_vk = a_sk.verifying_key();
2001 let a_id = MemberId::from(&a_vk);
2002
2003 let member_a = AuthorizedMember::new(
2004 Member {
2005 owner_member_id: owner_id,
2006 invited_by: owner_id,
2007 member_vk: a_vk,
2008 },
2009 &owner_sk,
2010 );
2011
2012 let ban = AuthorizedUserBan::new(
2013 UserBan {
2014 owner_member_id: owner_id,
2015 banned_at: SystemTime::now(),
2016 banned_user: a_id,
2017 },
2018 owner_id,
2019 &owner_sk,
2020 );
2021
2022 // Owner issued a v0 blob for A, then banned A. The blob
2023 // outlives the ban in the state (a peer might receive both
2024 // deltas in one batch). Without proper handling, the
2025 // exemption would resurrect A.
2026 let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
2027 member_id: a_id,
2028 secret_version: 0,
2029 ciphertext: vec![0u8; 16],
2030 nonce: [0u8; 12],
2031 sender_ephemeral_public_key: [0u8; 32],
2032 provider: owner_id,
2033 };
2034 let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
2035 secret_for_a,
2036 &owner_sk,
2037 );
2038
2039 let config = Configuration {
2040 max_members: 10,
2041 max_recent_messages: 100,
2042 ..Default::default()
2043 };
2044 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
2045
2046 let mut state = ChatRoomStateV1 {
2047 configuration: auth_config,
2048 members: MembersV1 {
2049 members: vec![member_a],
2050 },
2051 bans: crate::room_state::ban::BansV1(vec![ban]),
2052 secrets: crate::room_state::secret::RoomSecretsV1 {
2053 current_version: 0,
2054 versions: vec![],
2055 encrypted_secrets: vec![authorized_secret],
2056 },
2057 ..Default::default()
2058 };
2059
2060 // The owner-side flow is: apply ban delta -> members.apply_delta
2061 // removes A from members -> post_apply_cleanup runs. We
2062 // simulate the post-ban-prune state by manually removing A
2063 // from members (matching what `MembersV1::apply_delta` does
2064 // when it sees the ban), then run cleanup.
2065 state.members.members.retain(|m| m.member.id() != a_id);
2066
2067 state.post_apply_cleanup(¶ms).unwrap();
2068
2069 assert!(
2070 state.members.members.is_empty(),
2071 "banned member A must NOT be resurrected by post_apply_cleanup's \
2072 encrypted_secrets exemption — see IMPORTANT #4 on PR #272 review round 2"
2073 );
2074 // The ban itself must persist.
2075 assert_eq!(state.bans.0.len(), 1);
2076 assert_eq!(state.bans.0[0].ban.banned_user, a_id);
2077 }
2078
2079 /// IMPORTANT #5 (PR #272 review round 2): the
2080 /// `encrypted_secrets` exemption from `post_apply_cleanup` must
2081 /// be SCOPED to the current secret version. A member who has
2082 /// only old-version blobs and hasn't been re-issued at
2083 /// `current_version` is "stale" by the same definition as a
2084 /// member who joined and never authored, and must be pruned.
2085 ///
2086 /// Without this TTL, every ever-recipient + their entire
2087 /// invite-chain ancestor set would be exempt from cleanup
2088 /// forever — defeating the whole point of the inactivity prune.
2089 #[test]
2090 fn test_stale_secret_recipient_is_pruned_after_rotation() {
2091 use crate::room_state::privacy::RoomCipherSpec;
2092 use crate::room_state::secret::{AuthorizedSecretVersionRecord, SecretVersionRecordV1};
2093 use std::time::SystemTime;
2094
2095 let rng = &mut rand::thread_rng();
2096 let owner_sk = SigningKey::generate(rng);
2097 let owner_vk = owner_sk.verifying_key();
2098 let owner_id = MemberId::from(&owner_vk);
2099 let params = ChatRoomParametersV1 { owner: owner_vk };
2100
2101 let a_sk = SigningKey::generate(rng);
2102 let a_vk = a_sk.verifying_key();
2103 let a_id = MemberId::from(&a_vk);
2104
2105 let member_a = AuthorizedMember::new(
2106 Member {
2107 owner_member_id: owner_id,
2108 invited_by: owner_id,
2109 member_vk: a_vk,
2110 },
2111 &owner_sk,
2112 );
2113
2114 // A only has a v0 blob. The room has since rotated to v1
2115 // and A was not re-issued (e.g. they left / were
2116 // implicitly inactive at rotation time).
2117 let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
2118 member_id: a_id,
2119 secret_version: 0,
2120 ciphertext: vec![0u8; 16],
2121 nonce: [0u8; 12],
2122 sender_ephemeral_public_key: [0u8; 32],
2123 provider: owner_id,
2124 };
2125 let authorized_secret_v0 =
2126 crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
2127 secret_for_a,
2128 &owner_sk,
2129 );
2130
2131 let v1_record = AuthorizedSecretVersionRecord::new(
2132 SecretVersionRecordV1 {
2133 version: 1,
2134 cipher_spec: RoomCipherSpec::Aes256Gcm,
2135 created_at: SystemTime::now(),
2136 },
2137 &owner_sk,
2138 );
2139
2140 let config = Configuration {
2141 max_members: 10,
2142 max_recent_messages: 100,
2143 ..Default::default()
2144 };
2145 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
2146
2147 let mut state = ChatRoomStateV1 {
2148 configuration: auth_config,
2149 members: MembersV1 {
2150 members: vec![member_a],
2151 },
2152 secrets: crate::room_state::secret::RoomSecretsV1 {
2153 current_version: 1,
2154 versions: vec![v1_record],
2155 encrypted_secrets: vec![authorized_secret_v0],
2156 },
2157 ..Default::default()
2158 };
2159
2160 state.post_apply_cleanup(¶ms).unwrap();
2161
2162 assert!(
2163 state.members.members.is_empty(),
2164 "member A with ONLY a stale v0 blob (no v1 re-issue, no messages, no \
2165 DMs) must be pruned — see IMPORTANT #5 on PR #272 review round 2"
2166 );
2167 }
2168
2169 /// IMPORTANT #6 (PR #272 review round 2): ban-race convergence
2170 /// across peers receiving deltas in different orders. Both
2171 /// orderings — (add-X, ban-X) and (ban-X, add-X) — must
2172 /// converge with X removed, regardless of whether the
2173 /// owner-issued `encrypted_secret` for X arrives before or
2174 /// after the ban.
2175 ///
2176 /// This is the same convergence test pattern PR #240 used for
2177 /// DMs but applied to the new encrypted_secrets exemption.
2178 /// Without this test, a future regression that loosens the
2179 /// "members_by_id.contains_key" guard could leak X back into
2180 /// state via the exemption when the deltas land in the
2181 /// "wrong" order.
2182 #[test]
2183 fn test_ban_race_with_encrypted_secret_converges_to_pruned() {
2184 use crate::room_state::ban::{AuthorizedUserBan, UserBan};
2185 use std::time::SystemTime;
2186
2187 let rng = &mut rand::thread_rng();
2188 let owner_sk = SigningKey::generate(rng);
2189 let owner_vk = owner_sk.verifying_key();
2190 let owner_id = MemberId::from(&owner_vk);
2191 let params = ChatRoomParametersV1 { owner: owner_vk };
2192
2193 let x_sk = SigningKey::generate(rng);
2194 let x_vk = x_sk.verifying_key();
2195 let x_id = MemberId::from(&x_vk);
2196
2197 let member_x = AuthorizedMember::new(
2198 Member {
2199 owner_member_id: owner_id,
2200 invited_by: owner_id,
2201 member_vk: x_vk,
2202 },
2203 &owner_sk,
2204 );
2205
2206 let ban_x = AuthorizedUserBan::new(
2207 UserBan {
2208 owner_member_id: owner_id,
2209 banned_at: SystemTime::now(),
2210 banned_user: x_id,
2211 },
2212 owner_id,
2213 &owner_sk,
2214 );
2215
2216 let secret_for_x = crate::room_state::secret::EncryptedSecretForMemberV1 {
2217 member_id: x_id,
2218 secret_version: 0,
2219 ciphertext: vec![0u8; 16],
2220 nonce: [0u8; 12],
2221 sender_ephemeral_public_key: [0u8; 32],
2222 provider: owner_id,
2223 };
2224 let authorized_secret_x =
2225 crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
2226 secret_for_x,
2227 &owner_sk,
2228 );
2229
2230 let config = Configuration {
2231 max_members: 10,
2232 max_recent_messages: 100,
2233 ..Default::default()
2234 };
2235 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
2236
2237 // Build the FINAL converged state both peers should arrive
2238 // at: X is banned, X is not in members, the v0
2239 // encrypted_secret for X may or may not be present
2240 // depending on whether peer's secrets state pruned it.
2241 // We simulate the post-merge state where both deltas have
2242 // landed; the in-flight blob for X is still in state when
2243 // post_apply_cleanup runs.
2244 //
2245 // Peer A: applied [add-X@t0, ban-X@t1] — members.apply_delta
2246 // saw the ban and removed X from members. Then the
2247 // secrets delta arrived with a v0 blob for X. Final state:
2248 // members = [], bans = [ban-X], encrypted_secrets = [(x, 0)].
2249 let mut peer_a_state = ChatRoomStateV1 {
2250 configuration: auth_config.clone(),
2251 members: MembersV1 { members: vec![] },
2252 bans: crate::room_state::ban::BansV1(vec![ban_x.clone()]),
2253 secrets: crate::room_state::secret::RoomSecretsV1 {
2254 current_version: 0,
2255 versions: vec![],
2256 encrypted_secrets: vec![authorized_secret_x.clone()],
2257 },
2258 ..Default::default()
2259 };
2260 peer_a_state.post_apply_cleanup(¶ms).unwrap();
2261 assert!(
2262 peer_a_state.members.members.is_empty(),
2263 "peer A: X must remain pruned despite the encrypted_secret being present"
2264 );
2265
2266 // Peer B: applied [ban-X@t1, add-X@t0]. ban-X was applied
2267 // first; add-X arrived later but was rejected by
2268 // `MembersV1::apply_delta` because X is in the ban list.
2269 // Then the secrets delta arrived with a v0 blob for X.
2270 // Final state matches peer A's.
2271 let mut peer_b_state = ChatRoomStateV1 {
2272 configuration: auth_config,
2273 members: MembersV1 { members: vec![] },
2274 bans: crate::room_state::ban::BansV1(vec![ban_x]),
2275 secrets: crate::room_state::secret::RoomSecretsV1 {
2276 current_version: 0,
2277 versions: vec![],
2278 encrypted_secrets: vec![authorized_secret_x],
2279 },
2280 ..Default::default()
2281 };
2282 peer_b_state.post_apply_cleanup(¶ms).unwrap();
2283 assert!(
2284 peer_b_state.members.members.is_empty(),
2285 "peer B: X must remain pruned despite the encrypted_secret being present"
2286 );
2287
2288 // The two peers must converge to byte-identical members /
2289 // bans / encrypted_secrets state.
2290 assert_eq!(peer_a_state.members, peer_b_state.members);
2291 assert_eq!(peer_a_state.bans, peer_b_state.bans);
2292 assert_eq!(peer_a_state.secrets, peer_b_state.secrets);
2293
2294 // Suppress unused-variable lints — `member_x` is the seed
2295 // we used to derive `x_id` / `x_vk`; the convergence test
2296 // checks the AFTER-merge state where members is already
2297 // empty by construction.
2298 let _ = member_x;
2299 }
2300
2301 #[test]
2302 fn test_state_with_none_deltas() {
2303 let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
2304
2305 // Create a modified room_state with no changes (all deltas should be None)
2306 let modified_state = state.clone();
2307
2308 // Apply the delta
2309 let summary = state.summarize(&state, ¶meters);
2310 let delta = modified_state.delta(&state, ¶meters, &summary);
2311
2312 assert!(
2313 delta.is_none(),
2314 "Delta should be None when no changes are made"
2315 );
2316
2317 // Now, let's modify only one field and check if other deltas are None
2318 let mut partially_modified_state = state.clone();
2319 let new_config = Configuration {
2320 configuration_version: 2,
2321 ..partially_modified_state.configuration.configuration.clone()
2322 };
2323 partially_modified_state.configuration =
2324 AuthorizedConfigurationV1::new(new_config, &owner_signing_key);
2325
2326 let summary = state.summarize(&state, ¶meters);
2327 let delta = partially_modified_state
2328 .delta(&state, ¶meters, &summary)
2329 .unwrap();
2330
2331 // Check that only the configuration delta is Some, and others are None
2332 assert!(
2333 delta.configuration.is_some(),
2334 "Configuration delta should be Some"
2335 );
2336 assert!(delta.bans.is_none(), "Bans delta should be None");
2337 assert!(delta.members.is_none(), "Members delta should be None");
2338 assert!(
2339 delta.member_info.is_none(),
2340 "Member info delta should be None"
2341 );
2342 assert!(
2343 delta.recent_messages.is_none(),
2344 "Recent messages delta should be None"
2345 );
2346 assert!(delta.upgrade.is_none(), "Upgrade delta should be None");
2347
2348 // Apply the partial delta
2349 let mut new_state = state.clone();
2350 new_state
2351 .apply_delta(&state, ¶meters, &Some(delta))
2352 .unwrap();
2353
2354 assert_eq!(
2355 new_state, partially_modified_state,
2356 "State should be partially modified"
2357 );
2358 }
2359
2360 /// Regression test for freenet/river#127: the contract-migration upgrade
2361 /// pointer is delivered to the old contract as a minimal `apply_delta`,
2362 /// NOT as a full-state UPDATE.
2363 ///
2364 /// A full `UpdateData::State` is run through the old contract's
2365 /// `validate_state` -> `ChatRoomStateV1::verify`. The old code built that
2366 /// state with `..Default::default()`, whose `configuration` is unsigned,
2367 /// so verification failed with "Invalid signature" and the pointer never
2368 /// landed. Applying only the `upgrade` field as a delta runs
2369 /// `OptionalUpgradeV1::apply_delta`, which validates just the upgrade
2370 /// signature against the contract's owner parameter.
2371 #[test]
2372 fn test_upgrade_pointer_applies_as_delta() {
2373 use crate::room_state::upgrade::{AuthorizedUpgradeV1, UpgradeV1};
2374
2375 let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
2376 // Sanity: the baseline room state is itself valid and has no pointer.
2377 assert!(
2378 state.verify(&state, ¶meters).is_ok(),
2379 "baseline room state should verify"
2380 );
2381 assert!(
2382 state.upgrade.0.is_none(),
2383 "baseline room state should have no upgrade pointer"
2384 );
2385
2386 let upgrade = UpgradeV1 {
2387 owner_member_id: MemberId::from(¶meters.owner),
2388 version: 1,
2389 new_chatroom_address: blake3::Hash::from([7u8; 32]),
2390 };
2391 let authorized = AuthorizedUpgradeV1::new(upgrade, &owner_signing_key);
2392
2393 // FIX: apply the upgrade pointer as a minimal delta — the path the old
2394 // contract takes for `UpdateData::Delta`. The pointer must land and the
2395 // resulting state must still verify.
2396 let delta = ChatRoomStateV1Delta {
2397 upgrade: Some(authorized.clone()),
2398 ..Default::default()
2399 };
2400 let mut updated = state.clone();
2401 updated
2402 .apply_delta(&state, ¶meters, &Some(delta))
2403 .expect("applying the upgrade-pointer delta must succeed");
2404 assert_eq!(
2405 updated.upgrade.0.as_ref(),
2406 Some(&authorized),
2407 "the upgrade pointer must land on the old contract's state"
2408 );
2409 assert!(
2410 updated.verify(&updated, ¶meters).is_ok(),
2411 "the state with the upgrade pointer applied must still verify"
2412 );
2413
2414 // Why not a full-state UPDATE: a `..Default::default()` state — the old
2415 // (#127) approach — fails `verify` because its default `configuration`
2416 // is unsigned, so the runtime's `validate_state` rejected it.
2417 let buggy_full_state = ChatRoomStateV1 {
2418 upgrade: OptionalUpgradeV1(Some(authorized)),
2419 ..Default::default()
2420 };
2421 assert!(
2422 buggy_full_state
2423 .verify(&buggy_full_state, ¶meters)
2424 .is_err(),
2425 "a `..Default::default()` full-state upgrade must fail verification (the #127 bug)"
2426 );
2427 }
2428
2429 /// #411 round 8 item C: a state carrying DUPLICATE member_info records for a
2430 /// member (which `verify` accepts) is collapsed by `post_apply_cleanup` to
2431 /// exactly one canonical (highest-rank) record per member — killing the
2432 /// duplicate-SET byte-divergence between peers and bounding stored size.
2433 #[test]
2434 fn post_apply_cleanup_dedups_member_info_to_canonical() {
2435 use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
2436
2437 let rng = &mut rand::thread_rng();
2438 let owner_sk = SigningKey::generate(rng);
2439 let owner_vk = owner_sk.verifying_key();
2440 let owner_id = MemberId::from(&owner_vk);
2441 let params = ChatRoomParametersV1 { owner: owner_vk };
2442
2443 let config = Configuration {
2444 max_members: 10,
2445 max_recent_messages: 100,
2446 ..Default::default()
2447 };
2448 let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
2449
2450 // Member M kept present (has a message), with TWO member_info records:
2451 // grant @ v1 (a deputy) and revoke @ v2 (empty).
2452 let m_sk = SigningKey::generate(rng);
2453 let m_vk = m_sk.verifying_key();
2454 let m_id = MemberId::from(&m_vk);
2455 let member_m = AuthorizedMember::new(
2456 Member {
2457 owner_member_id: owner_id,
2458 invited_by: owner_id,
2459 member_vk: m_vk,
2460 },
2461 &owner_sk,
2462 );
2463 let msg_m = AuthorizedMessageV1::new(
2464 MessageV1 {
2465 room_owner: owner_id,
2466 author: m_id,
2467 time: SystemTime::now(),
2468 content: RoomMessageBody::public("hi".to_string()),
2469 },
2470 &m_sk,
2471 );
2472
2473 let dep = MemberId::from(&SigningKey::generate(rng).verifying_key());
2474 let mut g = MemberInfo::new_public(m_id, 1, "nick".to_string());
2475 g.deputies = vec![dep];
2476 let grant = AuthorizedMemberInfo::new_with_member_key(g, &m_sk);
2477 let mut r = MemberInfo::new_public(m_id, 2, "nick".to_string());
2478 r.deputies = vec![];
2479 let revoke = AuthorizedMemberInfo::new_with_member_key(r, &m_sk);
2480
2481 let mut state = ChatRoomStateV1 {
2482 configuration: auth_config,
2483 members: MembersV1 {
2484 members: vec![member_m],
2485 },
2486 member_info: MemberInfoV1 {
2487 // Duplicate: grant FIRST so a naive first-match would keep it.
2488 member_info: vec![grant, revoke],
2489 },
2490 recent_messages: MessagesV1 {
2491 messages: vec![msg_m],
2492 ..Default::default()
2493 },
2494 ..Default::default()
2495 };
2496
2497 state.post_apply_cleanup(¶ms).unwrap();
2498
2499 let recs: Vec<_> = state
2500 .member_info
2501 .member_info
2502 .iter()
2503 .filter(|i| i.member_info.member_id == m_id)
2504 .collect();
2505 assert_eq!(
2506 recs.len(),
2507 1,
2508 "exactly one member_info record for M after cleanup"
2509 );
2510 assert_eq!(
2511 recs[0].member_info.version, 2,
2512 "the surviving record is the v2 revoke"
2513 );
2514 assert!(
2515 recs[0].member_info.deputies.is_empty(),
2516 "the canonical (revoke) record has no deputies — revoked authority is not resurrected"
2517 );
2518 assert_eq!(
2519 state.member_info.deputies_of(m_id),
2520 &[] as &[MemberId],
2521 "deputies_of agrees with the deduped canonical record"
2522 );
2523 }
2524}