Skip to main content

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(&parameters.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                &parameters.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                        &parameters.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                &parameters.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, &parameters).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, &parameters);
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, &params).is_ok(),
695            "Initial state should verify: {:?}",
696            initial_state.verify(&initial_state, &params)
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, &params);
723        let delta = modified_for_delta.delta(&initial_state, &params, &summary);
724
725        let mut result_state = initial_state.clone();
726        let apply_result = result_state.apply_delta(&initial_state, &params, &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, &params).is_ok(),
755            "Result state should verify after orphaned ban cleanup: {:?}",
756            result_state.verify(&result_state, &params)
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(&params).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(&params).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, &params, &Some(delta))
960            .expect("atomic join delta should apply cleanly");
961
962        // Verify state is valid
963        state
964            .verify(&state, &params)
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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).unwrap();
1502            let mut twice = once.clone();
1503            twice.post_apply_cleanup(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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(&params).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, &parameters);
2310        let delta = modified_state.delta(&state, &parameters, &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, &parameters);
2327        let delta = partially_modified_state
2328            .delta(&state, &parameters, &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, &parameters, &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, &parameters).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(&parameters.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, &parameters, &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, &parameters).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, &parameters)
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(&params).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}