Skip to main content

river_core/room_state/
member_info.rs

1use crate::room_state::member::MemberId;
2use crate::room_state::privacy::SealedBytes;
3use crate::room_state::ChatRoomParametersV1;
4use crate::room_state::ChatRoomStateV1;
5use crate::util::{sign_struct, verify_struct};
6use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
7use freenet_scaffold::ComposableState;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashMap};
10
11/// Maximum number of deputies a single member may list in their `MemberInfo`,
12/// to bound state-bloat abuse (deputy ban authority, #410). A `MemberInfo`
13/// whose `deputies` list exceeds this is rejected by `MemberInfoV1::verify`.
14pub const MAX_DEPUTIES: usize = 64;
15
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
17pub struct MemberInfoV1 {
18    pub member_info: Vec<AuthorizedMemberInfo>,
19}
20
21impl MemberInfoV1 {
22    /// The CANONICAL `member_info` record for `member_id`: the highest-
23    /// `member_info_rank` (higher `version`, else greater signature DIGEST)
24    /// among ALL records present for that member, or `None` if there
25    /// is none.
26    ///
27    /// LOAD-BEARING (#411 round 8 item A). `verify` deliberately ACCEPTS a state
28    /// carrying more than one record per `member_id` (migration-safety — see its
29    /// comment), and a client can hold such a duplicate-containing full-state GET
30    /// before any cleanup runs. EVERY by-id reader/writer of `member_info` MUST
31    /// go through this selector: a bare first-match `.find()` can read a LOSING
32    /// (e.g. revoked) record, and a WRITER that republishes it at `version + 1`
33    /// would then resurrect that losing record at a HIGHER rank — reactivating
34    /// revoked deputy authority. This returns the SAME record `summarize` /
35    /// `apply_delta` converge on, so reads, writes, enforcement, and anti-entropy
36    /// all agree. (`post_apply_cleanup` additionally collapses duplicates via
37    /// [`Self::dedup_to_canonical`] once cleanup runs, but reads must not depend
38    /// on that having happened yet.)
39    pub fn canonical(&self, member_id: MemberId) -> Option<&AuthorizedMemberInfo> {
40        // The FIRST maximum wins, matching [`Self::dedup_to_canonical`] and
41        // `apply_delta` (both replace only on strict `>`). `Iterator::max_by_key`
42        // is deliberately NOT used here: it returns the LAST maximum, so with
43        // three selectors of "the canonical record" a tie would resolve one way
44        // on a freshly-GET'd full state and the other way after the next
45        // `apply_delta` ran dedup — a silent flip in `deputies_of`, i.e. in ban
46        // authority. A tie needs two records with the same version AND the same
47        // 128-bit signature digest, which [`SigDigest`] makes infeasible to mint,
48        // so this is closing a latent trap rather than a reachable bug.
49        //
50        // The rank is computed once per candidate rather than once per
51        // comparison, because `member_info_rank` hashes (see [`SigDigest`]).
52        let mut best: Option<(&AuthorizedMemberInfo, (u32, SigDigest))> = None;
53        for info in self
54            .member_info
55            .iter()
56            .filter(|info| info.member_info.member_id == member_id)
57        {
58            let rank = member_info_rank(info.member_info.version, &info.signature);
59            let better = match &best {
60                Some((_, best_rank)) => outranks(rank, *best_rank),
61                None => true,
62            };
63            if better {
64                best = Some((info, rank));
65            }
66        }
67        best.map(|(info, _)| info)
68    }
69
70    /// The deputies currently listed by `member_id`'s CANONICAL signed
71    /// `MemberInfo`, or an empty slice if that member has no info entry or no
72    /// deputies. Routes through [`Self::canonical`] so ban authority matches the
73    /// converged record even in the presence of duplicates (#410, #411 round 8).
74    pub fn deputies_of(&self, member_id: MemberId) -> &[MemberId] {
75        self.canonical(member_id)
76            .map(|info| info.member_info.deputies.as_slice())
77            .unwrap_or(&[])
78    }
79
80    /// Collapse any duplicate `member_info` records to the SINGLE canonical
81    /// (highest-`member_info_rank`) record per `member_id` (#411 round 8 item C /
82    /// security FINDING 2+3). Because `verify` accepts duplicates, a state can
83    /// hold several records for one member; without this, two peers holding
84    /// different duplicate SETS would diverge byte-for-byte forever (the raw
85    /// `member_info` vectors differ even though every canonical read agrees).
86    /// Dedup is a pure function of the converged state (max by `member_info_rank`)
87    /// so it is deterministic, idempotent, and order-independent; it also bounds
88    /// stored `member_info` to at most one record per member. Owner's record is
89    /// kept like any other (it is just another `member_id`). Runs in
90    /// `post_apply_cleanup`, NEVER in `verify`/`validate_state`, so the
91    /// permissionless migration PUT (which only runs `verify`) is unaffected.
92    pub fn dedup_to_canonical(&mut self) {
93        if self.member_info.len() < 2 {
94            return;
95        }
96        // Carry each incumbent's rank in the map rather than re-deriving it: a
97        // rank costs a blake3 hash (see [`SigDigest`]), and recomputing the
98        // incumbent's on every collision doubles that cost for no benefit.
99        // Ties keep the INCUMBENT (strict `>`), i.e. the first record in vector
100        // order, matching [`Self::canonical`] and `apply_delta`.
101        let mut best: HashMap<MemberId, ((u32, SigDigest), AuthorizedMemberInfo)> = HashMap::new();
102        for info in self.member_info.drain(..) {
103            let id = info.member_info.member_id;
104            let rank = member_info_rank(info.member_info.version, &info.signature);
105            match best.entry(id) {
106                std::collections::hash_map::Entry::Occupied(mut e) => {
107                    if outranks(rank, e.get().0) {
108                        e.insert((rank, info));
109                    }
110                }
111                std::collections::hash_map::Entry::Vacant(e) => {
112                    e.insert((rank, info));
113                }
114            }
115        }
116        self.member_info = best.into_values().map(|(_, info)| info).collect();
117        // Deterministic order (HashMap iteration order is not stable).
118        self.member_info
119            .sort_by_key(|info| info.member_info.member_id);
120    }
121}
122
123/// Deterministic ordering used to pick the canonical `MemberInfo` when two
124/// signed records for the SAME member collide (#411 round 4 item B).
125///
126/// Rule: **higher `version` wins; at equal version, the greater SIGNATURE DIGEST
127/// wins.** Two records with the same member and version but different content
128/// (e.g. different `deputies`) have different signatures — the signature is over
129/// the whole `MemberInfo` — so their digests differ and this breaks the tie
130/// deterministically. (Before freenet/river#571 the tiebreak compared raw
131/// signature bytes; the winner in a tie therefore changed with that PR, which is
132/// safe because every peer applies the same rule and the change re-keys the
133/// contract. See [`SigDigest`].)
134///
135/// This is a total ORDER on the returned `(version, digest)` pair, but only a
136/// total PREORDER on records: two distinct records rank equal exactly when their
137/// signatures collide under [`SigDigest`]. That tie is the dangerous case, not a
138/// benign one — see [`SigDigest`] for what a tie does to anti-entropy and for the
139/// 128-bit bound that is what actually keeps ties out of reach. "Records never
140/// tie" is a cryptographic property here, not a structural guarantee, so the
141/// three selectors that consume this order ([`MemberInfoV1::canonical`],
142/// [`MemberInfoV1::dedup_to_canonical`], and `apply_delta`) are nevertheless
143/// written to break a tie the SAME way (keep the first, i.e. replace only on
144/// strict `>`).
145///
146/// It is applied IDENTICALLY in [`ComposableState::apply_delta`] (conflict
147/// resolution), [`ComposableState::delta`], and [`ComposableState::summarize`]
148/// — the summary value IS this tuple — so anti-entropy can DETECT a
149/// same-version content difference and both peers converge on the same record.
150/// Without it, equal-version resolution was order-dependent AND the summary
151/// carried only the version, so anti-entropy saw "same version", sent no
152/// correction, and peers disagreed on ban authority permanently.
153fn member_info_rank(version: u32, signature: &Signature) -> (u32, SigDigest) {
154    (version, sig_digest(signature))
155}
156
157/// Whether `candidate` beats `incumbent` under [`member_info_rank`]'s order.
158///
159/// THE SINGLE IMPLEMENTATION OF THE TIE RULE. Five sites choose between two
160/// ranked records — [`MemberInfoV1::canonical`],
161/// [`MemberInfoV1::dedup_to_canonical`], `summarize`, `delta`, and `apply_delta`
162/// — and every one of them must break a tie the SAME way, keeping the incumbent.
163/// They previously each spelled `>` inline, which is one edit away from silent
164/// disagreement: relaxing any one of them to `>=` flips that site to last-wins
165/// while the others stay first-wins, and `deputies_of` then answers differently
166/// depending on which code path last touched the record — i.e. ban authority
167/// flips. That mutation left the whole suite green, because reaching the tie
168/// branch behaviorally needs a genuine [`SigDigest`] collision.
169///
170/// Routing all five through here means the rule has one definition and one test
171/// (`outranks_keeps_the_incumbent_on_a_tie`) rather than five prose assertions.
172/// Strict `>` is the rule: **ties keep the incumbent.**
173fn outranks(candidate: (u32, SigDigest), incumbent: (u32, SigDigest)) -> bool {
174    candidate > incumbent
175}
176
177/// 16-byte BLAKE3 digest of a signature: the equal-version tiebreak
178/// discriminator in [`member_info_rank`], and the value [`MemberInfoV1`]'s
179/// summary carries per member.
180///
181/// WHY A DIGEST AND NOT THE SIGNATURE (freenet/river#571, landed as PR #572;
182/// every "#571" elsewhere in this file is that ISSUE, not the PR): the summary carried
183/// the raw ed25519 `Signature` per member — ~124 of ~134 CBOR bytes per entry,
184/// about 92% of it. That summary is re-sent on every state change to every
185/// interested peer, and `interest_sync_summaries` was measured as the largest
186/// single consumer of outbound bytes on the Freenet network (49.8%). The
187/// signature is never verified here — it is only ever compared for equality and
188/// ordering — so a digest serves the identical purpose. Measured through the real
189/// `summarize()` on 470 entries with realistic `MemberId`s: **134.08 → 28.01 CBOR
190/// bytes per entry, a 4.8x reduction**. A 64-bit digest would be exactly 8 bytes
191/// per entry cheaper; the next paragraph is why those 8 bytes are bought
192/// deliberately. Both figures are measured, not derived, by
193/// `member_info_summary_stays_small_per_entry`, which rebuilds the old shape from
194/// the same records.
195///
196/// WHY 124 AND NOT 66, since 66 is the number the issue and the first draft of
197/// this change both used: `ed25519::Signature`'s `Serialize` calls
198/// `serialize_tuple(64)`, which ciborium encodes as a CBOR ARRAY of 64 integers,
199/// and a uniformly random byte costs 2 bytes there whenever it is >= 24. 66 is
200/// the CBOR BYTE STRING encoding — what River's own
201/// [`crate::room_state::direct_messages::SignatureBytes`] newtype produces via
202/// `serialize_bytes`, and what the deferred `DirectMessagesSummary` follow-up
203/// will actually be saving. The member_info summary never used that type. Two
204/// different encodings of the same 64 bytes; do not reason about both with one
205/// number.
206///
207/// The 29.1 KB mean `interest_sync_summaries` message that motivated #571 is a
208/// FLEET-WIDE mean across all rooms, so it is not this room's own summary size
209/// and the two figures must not be multiplied together: at the Official room's
210/// ~470 records the member_info term ALONE measures ~63 KB, well above the fleet
211/// mean, because that mean also averages in many far smaller rooms. No per-room
212/// summary measurement is on record, so no claim is made about what any single
213/// room's total summary weighed before this change. (The issue's own arithmetic
214/// did not close for the same reason this doc's did not: it used 66 rather than
215/// ~124 for the signature.)
216///
217/// WHY 128 BITS AND NOT 64: a collision here is not cosmetic, and it is not
218/// self-correcting. Two same-version records whose discriminators tie are
219/// INDISTINGUISHABLE to anti-entropy — `summarize` advertises an identical
220/// `(version, digest)` on both peers, `delta` filters on strict `>` so neither
221/// peer ever offers its record to the other, `apply_delta` replaces only on
222/// strict `>` so each keeps whichever arrived first, and full-state merge does
223/// not rescue it either (freenet-scaffold implements `merge` as summarize →
224/// delta → apply_delta). The two halves of the network then disagree
225/// permanently and SILENTLY on that member's `deputies`, i.e. on who may ban
226/// whom (#411 round 4 B is the bug that added this discriminator in the first
227/// place). A member SELF-SIGNS their own record and has unlimited grinding
228/// entropy for it — `preferred_nickname` is free-form and `deputies` entries
229/// are never validated for membership — so the attacker controls BOTH sides of
230/// the comparison: at 64 bits that is a ~2^32 birthday search, which is hours on
231/// commodity hardware. 128 bits puts it at ~2^64.
232///
233/// This mirrors [`crate::room_state::direct_messages::PurgeToken`], which
234/// derives a 16-byte BLAKE3 value from a signature for the same reason under a
235/// strictly WEAKER threat model (there the attacker cannot influence the other
236/// side of the comparison, and it still chose 128 bits). The two are
237/// deliberately NOT factored into a shared helper: each is an independent
238/// wire-format commitment — `PurgeToken`'s bytes live in stored state, these
239/// live in the summary — and they must stay free to evolve separately.
240///
241/// WHY BLAKE3 AND NOT `freenet_scaffold::util::fast_hash`: `fast_hash` is a
242/// base-31 polynomial, fine for the accidental collisions `MessageId` and
243/// `BanId` care about but trivially collidable by construction, which would
244/// price the attack above at roughly nothing regardless of its width.
245///
246/// WIRE FORMAT, load-bearing in two ways. freenet-core byte-compares
247/// `summarize_state` output for staleness, so this must be a fixed function of
248/// the signature bytes; and the digest orders the records, so every peer must
249/// derive the same bytes and compare them the same way. Both are pinned by
250/// `sig_digest_golden_vector`:
251///
252/// - the digest is the FIRST 16 bytes of `blake3(signature.to_bytes())`, kept in
253///   their natural order — there is no integer conversion, hence no endianness
254///   decision to get wrong (the 64-bit form needed `from_le_bytes` for this);
255/// - ordering is plain lexicographic over those bytes (the derived `Ord`);
256/// - it serializes as a CBOR byte string (17 bytes), via the hand-written
257///   `Serialize` below rather than the derive, which would emit a 16-element
258///   CBOR array — ~32 bytes for random digest content, since each byte >= 24
259///   costs two — and undo most of the saving.
260///
261/// None of the three may change without re-keying the contract. See
262/// `.claude/rules/contract-summary-determinism.md` and freenet/freenet-core#4857.
263#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
264pub struct SigDigest(pub [u8; 16]);
265
266impl Serialize for SigDigest {
267    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268        serializer.serialize_bytes(&self.0)
269    }
270}
271
272impl<'de> Deserialize<'de> for SigDigest {
273    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274        let bytes = <Vec<u8>>::deserialize(deserializer)?;
275        let arr: [u8; 16] = bytes.as_slice().try_into().map_err(|_| {
276            serde::de::Error::custom(format!(
277                "expected 16-byte SigDigest, got {} bytes",
278                bytes.len()
279            ))
280        })?;
281        Ok(SigDigest(arr))
282    }
283}
284
285/// The [`SigDigest`] of `signature`. See that type for the threat model, why the
286/// width is 128 bits, and the wire-format commitments this function makes.
287fn sig_digest(signature: &Signature) -> SigDigest {
288    let digest = blake3::hash(signature.to_bytes().as_ref());
289    let mut out = [0u8; 16];
290    out.copy_from_slice(&digest.as_bytes()[..16]);
291    SigDigest(out)
292}
293
294impl ComposableState for MemberInfoV1 {
295    type ParentState = ChatRoomStateV1;
296    /// `(version, signature-digest)` per member — i.e. exactly the value
297    /// [`member_info_rank`] returns. The digest is the equal-version tiebreak
298    /// discriminator; carrying it lets anti-entropy detect a content difference
299    /// at the SAME version (#411 B).
300    ///
301    /// This was `(u32, Signature)` until freenet/river#571. The raw 64-byte
302    /// signature was ~124 of ~134 CBOR bytes per entry (~92%), and this summary
303    /// is re-sent to every interested peer on every state change. See [`SigDigest`]
304    /// for why a digest is sufficient, why it is 128-bit blake3 rather than 64,
305    /// and why it serializes as a CBOR byte string.
306    ///
307    /// BTreeMap (not HashMap) so the ciborium-serialized summary bytes are
308    /// deterministic: freenet-core byte-compares `summarize_state` output for
309    /// staleness, and a HashMap iterates in a per-process-random order, making
310    /// two identical member_info sets summarize to different bytes → spurious
311    /// anti-entropy heals. See `.claude/rules/contract-summary-determinism.md`
312    /// and freenet/freenet-core#4857.
313    type Summary = BTreeMap<MemberId, (u32, SigDigest)>;
314    type Delta = Vec<AuthorizedMemberInfo>;
315    type Parameters = ChatRoomParametersV1;
316
317    fn verify(
318        &self,
319        parent_state: &Self::ParentState,
320        parameters: &Self::Parameters,
321    ) -> Result<(), String> {
322        let members_by_id = parent_state.members.members_by_member_id();
323        let owner_id = parameters.owner_id();
324
325        // NOTE (#411 round 7 / Codex P1 #3): `verify` deliberately does NOT reject
326        // a state that carries more than one `member_info` record for the same
327        // member. Rejecting would be migration-UNSAFE — the real Official-room
328        // state is re-PUT to the new contract through `verify`, and if it happens
329        // to contain a duplicate, rejection would strand the room EMPTY. Instead,
330        // duplicates are made HARMLESS by canonicalizing every by-id READER to the
331        // highest-`member_info_rank` record: `deputies_of` (enforcement) and
332        // `summarize` (the anti-entropy advertised value) both select the same
333        // winner, so two peers holding different duplicate SETS still agree on ban
334        // authority AND on the summary, and anti-entropy converges. A reject-in-
335        // `verify` guard could be added later as belt-and-suspenders ONLY once the
336        // real state is confirmed duplicate-free.
337
338        for member_info in &self.member_info {
339            let member_id = member_info.member_info.member_id;
340
341            // Bound the deputy list to prevent state-bloat abuse (#410).
342            if member_info.member_info.deputies.len() > MAX_DEPUTIES {
343                return Err(format!(
344                    "Member {:?} lists {} deputies, exceeding the maximum of {}",
345                    member_id,
346                    member_info.member_info.deputies.len(),
347                    MAX_DEPUTIES
348                ));
349            }
350
351            if member_id == owner_id {
352                // If this is the owner's member info, verify against owner's key
353                member_info.verify_signature(parameters)?;
354            } else {
355                // For non-owner members, verify they exist in members list
356                let member = members_by_id.get(&member_id).ok_or_else(|| {
357                    format!("MemberInfo exists for non-existent member: {:?}", member_id)
358                })?;
359
360                // Verify the signature with member's key
361                member_info.verify_signature_with_key(&member.member.member_vk)?;
362            }
363        }
364        Ok(())
365    }
366
367    fn summarize(
368        &self,
369        _parent_state: &Self::ParentState,
370        _parameters: &Self::Parameters,
371    ) -> Self::Summary {
372        // Carry the signature DIGEST alongside the version so anti-entropy can
373        // detect a SAME-version content difference and correct it (#411 round
374        // 4 B).
375        //
376        // Fold keeping the HIGHEST-`member_info_rank` record per member (#411
377        // round 7 / Codex P1 #3), NOT a plain `.collect()` (which keeps whichever
378        // duplicate was iterated LAST). If a state holds two records for one
379        // member, the advertised `(version, digest)` MUST match the record
380        // `deputies_of` enforces on, or a peer with a different duplicate set
381        // would advertise a different summary and anti-entropy would never
382        // reconcile. Migration-safe: `verify` still accepts duplicates.
383        let mut summary: Self::Summary = BTreeMap::new();
384        for info in &self.member_info {
385            // The summary value IS the rank, so the fold compares tuples
386            // directly rather than re-deriving a rank from a stored signature.
387            let candidate = member_info_rank(info.member_info.version, &info.signature);
388            summary
389                .entry(info.member_info.member_id)
390                .and_modify(|existing| {
391                    if outranks(candidate, *existing) {
392                        *existing = candidate;
393                    }
394                })
395                .or_insert(candidate);
396        }
397        summary
398    }
399
400    fn delta(
401        &self,
402        _parent_state: &Self::ParentState,
403        _parameters: &Self::Parameters,
404        old_state_summary: &Self::Summary,
405    ) -> Option<Self::Delta> {
406        let delta: Vec<AuthorizedMemberInfo> = self
407            .member_info
408            .iter()
409            .filter(|info| {
410                // Include if the member is absent from the old summary, OR this
411                // record OUTRANKS what the old summary has (higher version, or
412                // equal version with a greater signature digest). The equal-version arm
413                // is what lets a same-version content difference propagate (#411
414                // round 4 B) — without it, anti-entropy would never send the
415                // correction and peers would disagree on deputies forever.
416                match old_state_summary.get(&info.member_info.member_id) {
417                    None => true,
418                    // The summary value IS the rank (version, signature digest),
419                    // so compare against it directly.
420                    Some(old_rank) => outranks(
421                        member_info_rank(info.member_info.version, &info.signature),
422                        *old_rank,
423                    ),
424                }
425            })
426            .cloned()
427            .collect();
428
429        if delta.is_empty() {
430            None
431        } else {
432            Some(delta)
433        }
434    }
435
436    fn apply_delta(
437        &mut self,
438        parent_state: &Self::ParentState,
439        parameters: &Self::Parameters,
440        delta: &Option<Self::Delta>,
441    ) -> Result<(), String> {
442        let max_nickname_size = parent_state.configuration.configuration.max_nickname_size;
443
444        if let Some(delta) = delta {
445            for member_info in delta {
446                let member_id = &member_info.member_info.member_id;
447
448                // Validate nickname declared length
449                if member_info.member_info.preferred_nickname.declared_len() > max_nickname_size {
450                    return Err(format!(
451                        "Nickname declared length {} exceeds max_nickname_size {}",
452                        member_info.member_info.preferred_nickname.declared_len(),
453                        max_nickname_size
454                    ));
455                }
456
457                // Enforce the deputy-list cap at the DELTA boundary too — `verify`
458                // rejects an over-cap record on stored state, but without this an
459                // over-cap self-signed record would enter state via a delta and
460                // then block new-joiner / migration full-state validation (#410).
461                // SKIP the offending entry (like the removed-member case below)
462                // rather than erroring the whole delta: erroring would let one
463                // malicious over-cap record deadlock every full-state merge that
464                // carries it (the receiver would reject the entire state and never
465                // converge). Skipping is deterministic across peers and drops only
466                // the bad entry.
467                if member_info.member_info.deputies.len() > MAX_DEPUTIES {
468                    continue;
469                }
470
471                // Check if this is the room owner
472                if *member_id == parameters.owner_id() {
473                    // If it's the owner, verify against the room owner's key
474                    member_info.verify_signature(parameters)?;
475                } else {
476                    // For non-owners, verify they exist and check their signature.
477                    // If the member was removed (e.g. banned or max_members), skip
478                    // this entry — retention cleanup below will handle it.
479                    let members = parent_state.members.members_by_member_id();
480                    let member = match members.get(member_id) {
481                        Some(m) => m,
482                        None => continue,
483                    };
484                    member_info.verify_signature_with_key(&member.member.member_vk)?;
485                }
486
487                // Update or add the member info. Conflict resolution uses the
488                // total, deterministic `member_info_rank` order (higher version,
489                // else greater signature digest) so that two DIFFERENT records for the
490                // same member at the SAME version resolve identically regardless
491                // of delta arrival order (#411 round 4 B). Using only
492                // `version >` (as before) left equal-version conflicts
493                // order-dependent, so peers could permanently disagree on
494                // `deputies` (and therefore on ban authority).
495                if let Some(existing_info) = self
496                    .member_info
497                    .iter_mut()
498                    .find(|info| info.member_info.member_id == *member_id)
499                {
500                    if outranks(
501                        member_info_rank(member_info.member_info.version, &member_info.signature),
502                        member_info_rank(
503                            existing_info.member_info.version,
504                            &existing_info.signature,
505                        ),
506                    ) {
507                        *existing_info = member_info.clone();
508                    }
509                } else {
510                    self.member_info.push(member_info.clone());
511                }
512            }
513        }
514        // Always remove any member info that is not in parent_state.members
515        let member_map = parent_state.members.members_by_member_id();
516        self.member_info.retain(|info| {
517            parameters.owner_id() == info.member_info.member_id
518                || member_map.contains_key(&info.member_info.member_id)
519        });
520
521        // Write-side dedup (#411 round 8 Task 2 / item A): the update path above
522        // uses first-match to locate the record to update, so if the state
523        // already held DUPLICATES for a member (a full-state PUT/GET seeds them,
524        // and `verify` accepts them), a delta could update/leave a stale
525        // lower-rank one. Collapse to the single canonical (highest-rank) record
526        // per member here so the STORED vector is duplicate-free after every
527        // apply, not only after `post_apply_cleanup`. Deterministic /
528        // order-independent (max by `member_info_rank`); a no-op on the common
529        // duplicate-free case. Readers still canonicalize and must not depend on
530        // this having run (`MemberInfoV1::canonical`). `dedup_to_canonical` also
531        // sorts for deterministic ordering (CRDT convergence requirement); the
532        // <2-record case it skips is trivially sorted already.
533        self.dedup_to_canonical();
534
535        Ok(())
536    }
537}
538
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540pub struct AuthorizedMemberInfo {
541    pub member_info: MemberInfo,
542    pub signature: Signature,
543}
544
545impl AuthorizedMemberInfo {
546    pub fn new(member_info: MemberInfo, owner_signing_key: &SigningKey) -> Self {
547        let signature = sign_struct(&member_info, owner_signing_key);
548        Self {
549            member_info,
550            signature,
551        }
552    }
553
554    pub fn new_with_member_key(member_info: MemberInfo, member_signing_key: &SigningKey) -> Self {
555        let signature = sign_struct(&member_info, member_signing_key);
556        Self {
557            member_info,
558            signature,
559        }
560    }
561
562    /// Create an AuthorizedMemberInfo with a pre-computed signature.
563    /// Use this when signing is done externally (e.g., via delegate).
564    pub fn with_signature(member_info: MemberInfo, signature: Signature) -> Self {
565        Self {
566            member_info,
567            signature,
568        }
569    }
570
571    pub fn verify_signature(&self, parameters: &ChatRoomParametersV1) -> Result<(), String> {
572        self.verify_signature_with_key(&parameters.owner)
573    }
574
575    pub fn verify_signature_with_key(&self, verifying_key: &VerifyingKey) -> Result<(), String> {
576        verify_struct(&self.member_info, &self.signature, verifying_key)
577            .map_err(|e| format!("Invalid signature: {}", e))
578    }
579
580    // Helper method for tests
581    #[cfg(test)]
582    pub fn with_invalid_signature(mut self) -> Self {
583        self.signature = Signature::from_bytes(&[0; 64]);
584        self
585    }
586}
587
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589pub struct MemberInfo {
590    pub member_id: MemberId,
591    pub version: u32,
592    pub preferred_nickname: SealedBytes,
593    /// Members this member has deputized to ban within this member's invite
594    /// subtree (deputy ban authority, #410). Empty for the vast majority of
595    /// members.
596    ///
597    /// LOAD-BEARING: this MUST be the LAST field and MUST keep BOTH
598    /// `#[serde(default)]` (so pre-#410 records — which have no `deputies`
599    /// key — still deserialize) AND `skip_serializing_if = "Vec::is_empty"`
600    /// (so an EMPTY list serializes byte-identically to the old 3-field
601    /// record). `MemberInfo` is INDIVIDUALLY signed over its ciborium bytes
602    /// (`AuthorizedMemberInfo`), so a plain `#[serde(default)]` alone would
603    /// re-serialize every existing member's record with an extra field,
604    /// breaking their signature on migration and stranding every existing
605    /// room. Never reorder the first three fields. Pinned by
606    /// `empty_deputies_serializes_identically_to_legacy_member_info`.
607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
608    pub deputies: Vec<MemberId>,
609}
610
611impl MemberInfo {
612    /// Create a new member info with a public nickname
613    pub fn new_public(member_id: MemberId, version: u32, nickname: String) -> Self {
614        Self {
615            member_id,
616            version,
617            preferred_nickname: SealedBytes::public(nickname.into_bytes()),
618            deputies: Vec::new(),
619        }
620    }
621
622    /// Create a new member info with a private nickname
623    pub fn new_private(
624        member_id: MemberId,
625        version: u32,
626        ciphertext: Vec<u8>,
627        nonce: [u8; 12],
628        secret_version: u32,
629        declared_len: u32,
630    ) -> Self {
631        Self {
632            member_id,
633            version,
634            preferred_nickname: SealedBytes::private(
635                ciphertext,
636                nonce,
637                secret_version,
638                declared_len,
639            ),
640            deputies: Vec::new(),
641        }
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::room_state::member::{AuthorizedMember, Member};
649    use ed25519_dalek::{Signer, SigningKey};
650    use rand::rngs::OsRng;
651
652    fn create_test_member_info(member_id: MemberId) -> MemberInfo {
653        MemberInfo::new_public(member_id, 1, "TestUser".to_string())
654    }
655
656    /// GOLDEN VECTOR for [`SigDigest`] — ONE fixed signature, ONE fixed expected
657    /// digest, ONE fixed expected CBOR encoding.
658    ///
659    /// This pins the four things a peer must agree with every other peer on, and
660    /// which cannot change without re-keying the contract: that the hash is
661    /// blake3 over `signature.to_bytes()`, that the digest is the FIRST 16 bytes
662    /// of it, that those bytes are kept in their natural order, and that the
663    /// value serializes as a 17-byte CBOR byte string rather than a 32-byte CBOR
664    /// array.
665    ///
666    /// WHY A FIXED VECTOR AND NOT ONLY THE EXISTING ORACLES: every other check
667    /// on the digest — `deputy_ban_test`'s winner oracles and `room_data`'s
668    /// key-search fixture — compares digests of RANDOMLY-keyed signatures. A
669    /// change to the byte order leaves those comparisons agreeing about half the
670    /// time, i.e. an INTERMITTENT detector, which this project treats as a broken
671    /// one. Measured twice with the digest bytes reversed: those oracles let the
672    /// change through in 11 of 30 runs in one sample and 1 of 12 in an
673    /// independent reproduction. The rate depends on the keys a run happens to
674    /// draw and is not worth pinning down; what matters is that it is not 0, so
675    /// the detector is a coin flip rather than a check. A fixed input makes the
676    /// detection deterministic. (Byte order was
677    /// a live risk while the digest was a `u64` built with `from_le_bytes`; the
678    /// `[u8; 16]` form removes the conversion, but "which 16 bytes, in what
679    /// order" still has to be pinned, and the encoding certainly does.)
680    ///
681    /// The expected bytes were produced with the `b3sum` 1.8.3 CLI, outside this
682    /// crate, rather than by running the code under test:
683    ///   `printf '%b' "\x00\x01...\x3f" | b3sum` →
684    ///   `4eed7141ea4a5cd4b788606bd23f46e212af9cacebacdc7d1f4c6dc7f2511b98`.
685    /// That is a separate binary but the same reference BLAKE3 implementation
686    /// the `blake3` crate wraps, so this pins OUR choices (which hash, which
687    /// bytes, what order, what encoding) — it is not an independent check that
688    /// BLAKE3 itself is correct, and is not claimed to be.
689    #[test]
690    fn sig_digest_golden_vector() {
691        // Bytes 0x00..=0x3f. `Signature::from_bytes` is infallible and does not
692        // validate the encoded point, and `to_bytes` returns these bytes back
693        // unchanged, so the digest input is exactly this fixed array.
694        let mut raw = [0u8; 64];
695        for (i, b) in raw.iter_mut().enumerate() {
696            *b = i as u8;
697        }
698        let signature = Signature::from_bytes(&raw);
699        assert_eq!(
700            signature.to_bytes(),
701            raw,
702            "precondition: the fixture signature must round-trip to the bytes hashed"
703        );
704
705        const EXPECTED: [u8; 16] = [
706            0x4e, 0xed, 0x71, 0x41, 0xea, 0x4a, 0x5c, 0xd4, 0xb7, 0x88, 0x60, 0x6b, 0xd2, 0x3f,
707            0x46, 0xe2,
708        ];
709
710        let digest = sig_digest(&signature);
711        assert_eq!(
712            digest.0, EXPECTED,
713            "sig_digest changed. This is a WIRE-FORMAT and ORDERING change: every \
714             peer derives the tiebreak discriminator with this exact function, and \
715             freenet-core byte-compares summarize_state output for staleness. If \
716             the change is intended it re-keys the room contract — follow the \
717             migration ritual and update this vector deliberately."
718        );
719
720        // Reversing the digest bytes must be observably different, so the vector
721        // genuinely constrains ORDER and not merely the multiset of bytes.
722        let mut reversed = EXPECTED;
723        reversed.reverse();
724        assert_ne!(
725            digest.0, reversed,
726            "the golden vector must distinguish byte order"
727        );
728
729        // The encoding is the other half of the commitment: a CBOR byte string
730        // (major type 2, length 16 => header 0x50) at 17 bytes total. The derived
731        // Serialize would emit a 16-element CBOR array at 32 bytes, undoing most
732        // of the saving freenet/river#571 exists for.
733        let mut encoded = Vec::new();
734        ciborium::ser::into_writer(&digest, &mut encoded).expect("serialize SigDigest");
735        let mut want = vec![0x50u8];
736        want.extend_from_slice(&EXPECTED);
737        assert_eq!(
738            encoded, want,
739            "SigDigest must serialize as a 17-byte CBOR byte string"
740        );
741
742        // And it must survive the round-trip the contract actually performs
743        // (summarize_state serializes; get_state_delta deserializes).
744        let decoded: SigDigest =
745            ciborium::de::from_reader(encoded.as_slice()).expect("deserialize SigDigest");
746        assert_eq!(
747            decoded, digest,
748            "SigDigest must round-trip through ciborium"
749        );
750    }
751
752    /// The three selectors of "the canonical record" must break a rank TIE the
753    /// same way. [`SigDigest`] makes a tie between DISTINCT records infeasible to
754    /// mint, but two byte-identical duplicates of the SAME record tie trivially,
755    /// and `Iterator::max_by_key` (which `canonical` used before) returns the
756    /// LAST maximum while `dedup_to_canonical` and `apply_delta` keep the FIRST.
757    /// Left disagreeing, a state holding duplicates would answer `deputies_of`
758    /// one way on a freshly-GET'd full state and the other way after the next
759    /// `apply_delta` ran dedup.
760    /// [`outranks`] is the single definition of the tie rule that all five rank
761    /// consumers share, so this is the one place it is pinned.
762    ///
763    /// Relaxing `>` to `>=` at any individual call site used to leave the entire
764    /// suite green: reaching the tie branch behaviorally needs a genuine
765    /// [`SigDigest`] collision, since `verify` checks every stored record's
766    /// signature. That made the tie direction prose-only, which is exactly the
767    /// state that let `canonical` (last-wins) and `dedup_to_canonical`
768    /// (first-wins) disagree for as long as they did.
769    #[test]
770    fn outranks_keeps_the_incumbent_on_a_tie() {
771        let lo = SigDigest([0x11; 16]);
772        let hi = SigDigest([0x22; 16]);
773
774        // The load-bearing case: equal rank must NOT outrank. `>=` fails here.
775        assert!(
776            !outranks((1, lo), (1, lo)),
777            "a tie must keep the INCUMBENT — all five consumers depend on this"
778        );
779
780        // Version dominates the digest in both directions.
781        assert!(outranks((2, lo), (1, hi)), "higher version wins");
782        assert!(!outranks((1, hi), (2, lo)), "lower version loses");
783
784        // At equal version the digest decides, lexicographically over its bytes.
785        assert!(
786            outranks((1, hi), (1, lo)),
787            "greater digest wins at equal version"
788        );
789        assert!(
790            !outranks((1, lo), (1, hi)),
791            "lesser digest loses at equal version"
792        );
793    }
794
795    /// `canonical` must select on the DIGEST, not merely on the version.
796    ///
797    /// Without this, a mutation that compares only `version` (keeping the first
798    /// on a tie, matching the real tie direction) passes every river-core test;
799    /// only a river-ui test catches it. Deterministic by construction: two fixed
800    /// signatures whose digests are known to differ, so there is no retry loop
801    /// and no randomness.
802    #[test]
803    fn canonical_selects_by_digest_not_just_version() {
804        let mut csprng = OsRng;
805        let member_sk = SigningKey::generate(&mut csprng);
806        let member_id = MemberId::from(&member_sk.verifying_key());
807
808        // blake3([1; 64]) = 29c04cc4...  blake3([2; 64]) = fe969aba...
809        // so the [2; 64] record has the greater digest and MUST win. (Computed
810        // with the `b3sum` CLI, not by calling `sig_digest`.)
811        let low_sig = Signature::from_bytes(&[1u8; 64]);
812        let high_sig = Signature::from_bytes(&[2u8; 64]);
813        assert!(
814            sig_digest(&high_sig) > sig_digest(&low_sig),
815            "fixture precondition: [2; 64] must digest greater than [1; 64]"
816        );
817
818        let mut winner_info = MemberInfo::new_public(member_id, 1, "Same".to_string());
819        winner_info.deputies = vec![member_id];
820        let winner = AuthorizedMemberInfo::with_signature(winner_info, high_sig);
821        let loser = AuthorizedMemberInfo::with_signature(
822            MemberInfo::new_public(member_id, 1, "Same".to_string()),
823            low_sig,
824        );
825
826        // Loser FIRST, so a version-only comparison that keeps the first (the
827        // real tie direction) returns the wrong record.
828        let state = MemberInfoV1 {
829            member_info: vec![loser, winner.clone()],
830        };
831
832        assert_eq!(
833            state.canonical(member_id),
834            Some(&winner),
835            "canonical must break the equal-version tie on the digest, not fall \
836             back to vector position"
837        );
838        assert_eq!(
839            state.deputies_of(member_id),
840            &[member_id],
841            "deputies_of must follow canonical's choice — this is the ban-authority path"
842        );
843    }
844
845    /// The two records are built with `with_signature` so they share a signature
846    /// while carrying DIFFERENT `deputies`. That is exactly what a [`SigDigest`]
847    /// collision would look like to these two functions, which are pure
848    /// orderings and never verify a signature — and it is the only way to reach
849    /// the tie branch without actually finding a 128-bit collision. Records built
850    /// the normal way cannot be used here: ed25519 signing is deterministic and
851    /// covers the whole `MemberInfo`, so two records that tie on rank are
852    /// byte-identical, and the assertion would hold under either tie direction.
853    #[test]
854    fn canonical_and_dedup_break_rank_ties_identically() {
855        let mut csprng = OsRng;
856        let member_sk = SigningKey::generate(&mut csprng);
857        let member_id = MemberId::from(&member_sk.verifying_key());
858        let other_id = MemberId::from(&SigningKey::generate(&mut csprng).verifying_key());
859
860        let signed = AuthorizedMemberInfo::new_with_member_key(
861            MemberInfo::new_public(member_id, 1, "Tie".to_string()),
862            &member_sk,
863        );
864        let shared_signature = signed.signature;
865
866        let mut with_deputy = MemberInfo::new_public(member_id, 1, "Tie".to_string());
867        with_deputy.deputies = vec![other_id];
868
869        let first = AuthorizedMemberInfo::with_signature(
870            MemberInfo::new_public(member_id, 1, "Tie".to_string()),
871            shared_signature,
872        );
873        let second = AuthorizedMemberInfo::with_signature(with_deputy, shared_signature);
874
875        assert_ne!(first, second, "precondition: the records must differ");
876        assert_eq!(
877            member_info_rank(first.member_info.version, &first.signature),
878            member_info_rank(second.member_info.version, &second.signature),
879            "precondition: the records must nevertheless tie on rank"
880        );
881
882        let mut state = MemberInfoV1 {
883            member_info: vec![first.clone(), second],
884        };
885        let picked = state.canonical(member_id).cloned();
886        state.dedup_to_canonical();
887
888        assert_eq!(
889            state.member_info.len(),
890            1,
891            "dedup must collapse the tied duplicate"
892        );
893        assert_eq!(
894            picked.as_ref(),
895            state.member_info.first(),
896            "canonical() and dedup_to_canonical() must keep the SAME record on a tie \
897             (`max_by_key` keeps the LAST maximum, dedup keeps the FIRST — using both \
898              makes deputies_of flip after an unrelated apply_delta)"
899        );
900        // Both keep the FIRST record in vector order (replace only on strict `>`).
901        assert_eq!(
902            picked.as_ref(),
903            Some(&first),
904            "the tie must keep the first record"
905        );
906    }
907
908    /// LOAD-BEARING regression test (issue #410).
909    ///
910    /// `MemberInfo` is individually signed over its ciborium bytes
911    /// (`AuthorizedMemberInfo::new*` -> `sign_struct`; `verify_signature`
912    /// re-serializes and checks). Adding `deputies` with a PLAIN
913    /// `#[serde(default)]` would make the new WASM re-serialize a 4-field
914    /// struct, changing the bytes and breaking every existing member's
915    /// signature -> `validate_state` rejects the permissionless migration PUT
916    /// -> every existing room migrates to empty. The
917    /// `skip_serializing_if = "Vec::is_empty"` attribute makes an empty
918    /// `deputies` list serialize byte-identically to the old 3-field record,
919    /// so old signatures still verify.
920    ///
921    /// This test constructs the OLD 3-field shape, signs its ciborium bytes,
922    /// and asserts the new `MemberInfo` with an empty `deputies` list (a)
923    /// serializes to byte-identical bytes and (b) still verifies against that
924    /// old signature. It MUST fail if `skip_serializing_if` is dropped.
925    #[test]
926    fn empty_deputies_serializes_identically_to_legacy_member_info() {
927        use crate::util::{sign_struct, verify_struct};
928
929        // Exact mirror of the pre-#410 3-field MemberInfo layout, in order.
930        #[derive(Serialize)]
931        struct OldMemberInfo {
932            member_id: MemberId,
933            version: u32,
934            preferred_nickname: SealedBytes,
935        }
936
937        let signing_key = SigningKey::generate(&mut OsRng);
938        let member_id: MemberId = signing_key.verifying_key().into();
939        let nickname = SealedBytes::public("LegacyNick".to_string().into_bytes());
940
941        let old = OldMemberInfo {
942            member_id,
943            version: 7,
944            preferred_nickname: nickname.clone(),
945        };
946        // New struct: same first three fields, EMPTY deputies.
947        let new_empty = MemberInfo {
948            member_id,
949            version: 7,
950            preferred_nickname: nickname.clone(),
951            deputies: Vec::new(),
952        };
953
954        // (a) direct byte-identity of the ciborium serialization.
955        let mut old_bytes = Vec::new();
956        ciborium::ser::into_writer(&old, &mut old_bytes).unwrap();
957        let mut new_bytes = Vec::new();
958        ciborium::ser::into_writer(&new_empty, &mut new_bytes).unwrap();
959        assert_eq!(
960            old_bytes, new_bytes,
961            "MemberInfo with empty deputies MUST serialize byte-identically to \
962             the legacy 3-field record; dropping skip_serializing_if breaks this \
963             and strands every existing room (issue #410)"
964        );
965
966        // (b) a signature over the OLD record still verifies against the NEW struct.
967        let signature = sign_struct(&old, &signing_key);
968        assert!(
969            verify_struct(&new_empty, &signature, &signing_key.verifying_key()).is_ok(),
970            "signature over legacy MemberInfo bytes must still verify against the \
971             new struct with empty deputies (proves byte-identical serialization)"
972        );
973
974        // Sanity: a NON-empty deputies list MUST change the bytes (proves the
975        // field really is serialized when populated, so it is not a silent no-op).
976        let with_deputy = MemberInfo {
977            member_id,
978            version: 7,
979            preferred_nickname: nickname,
980            deputies: vec![member_id],
981        };
982        let mut with_deputy_bytes = Vec::new();
983        ciborium::ser::into_writer(&with_deputy, &mut with_deputy_bytes).unwrap();
984        assert_ne!(
985            old_bytes, with_deputy_bytes,
986            "a populated deputies list must change the serialized bytes"
987        );
988    }
989
990    #[test]
991    fn test_member_info_v1_default() {
992        let default_member_info = MemberInfoV1::default();
993        assert!(default_member_info.member_info.is_empty());
994    }
995
996    #[test]
997    fn test_member_info_v1_verify() {
998        let owner_signing_key = SigningKey::generate(&mut OsRng);
999        let owner_verifying_key = owner_signing_key.verifying_key();
1000        let owner_id = owner_verifying_key.into();
1001
1002        let member_signing_key = SigningKey::generate(&mut OsRng);
1003        let member_verifying_key = member_signing_key.verifying_key();
1004        let member_id = member_verifying_key.into();
1005
1006        let member_info = create_test_member_info(member_id);
1007        let authorized_member_info = AuthorizedMemberInfo::new(member_info, &member_signing_key);
1008
1009        let mut member_info_v1 = MemberInfoV1::default();
1010        member_info_v1
1011            .member_info
1012            .push(authorized_member_info.clone());
1013
1014        let mut parent_state = ChatRoomStateV1::default();
1015        let member = Member {
1016            owner_member_id: owner_id,
1017            invited_by: owner_id,
1018            member_vk: member_verifying_key,
1019        };
1020        let authorized_member = AuthorizedMember::new(member, &owner_signing_key);
1021        parent_state.members.members.push(authorized_member);
1022
1023        let parameters = ChatRoomParametersV1 {
1024            owner: owner_verifying_key,
1025        };
1026
1027        let result = member_info_v1.verify(&parent_state, &parameters);
1028        assert!(
1029            result.is_ok(),
1030            "Verification failed: {}",
1031            result.unwrap_err()
1032        );
1033
1034        // Test with non-existent member
1035        let non_existent_member_id = SigningKey::generate(&mut OsRng).verifying_key().into();
1036        let non_existent_member_info = create_test_member_info(non_existent_member_id);
1037        let non_existent_authorized_member_info =
1038            AuthorizedMemberInfo::new(non_existent_member_info, &owner_signing_key);
1039        member_info_v1
1040            .member_info
1041            .push(non_existent_authorized_member_info);
1042
1043        let verify_result = member_info_v1.verify(&parent_state, &parameters);
1044        assert!(
1045            verify_result.is_err(),
1046            "Expected verification to fail, but it succeeded"
1047        );
1048        if let Err(err) = verify_result {
1049            assert!(
1050                err.contains("MemberInfo exists for non-existent member"),
1051                "Unexpected error message: {}",
1052                err
1053            );
1054        }
1055
1056        // Test with invalid signature
1057        let invalid_authorized_member_info = authorized_member_info.with_invalid_signature();
1058        member_info_v1.member_info.clear();
1059        member_info_v1
1060            .member_info
1061            .push(invalid_authorized_member_info);
1062
1063        let verify_result = member_info_v1.verify(&parent_state, &parameters);
1064        assert!(
1065            verify_result.is_err(),
1066            "Expected verification to fail, but it succeeded"
1067        );
1068        if let Err(err) = verify_result {
1069            assert!(
1070                err.contains("Invalid signature"),
1071                "Unexpected error message: {}",
1072                err
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn test_member_info_v1_summarize() {
1079        let owner_signing_key = SigningKey::generate(&mut OsRng);
1080        let member_id = SigningKey::generate(&mut OsRng).verifying_key().into();
1081        let member_info = create_test_member_info(member_id);
1082        let authorized_member_info = AuthorizedMemberInfo::new(member_info, &owner_signing_key);
1083
1084        let mut member_info_v1 = MemberInfoV1::default();
1085        member_info_v1.member_info.push(authorized_member_info);
1086
1087        let parent_state = ChatRoomStateV1::default();
1088        let parameters = ChatRoomParametersV1 {
1089            owner: owner_signing_key.verifying_key(),
1090        };
1091
1092        let summary = member_info_v1.summarize(&parent_state, &parameters);
1093        assert_eq!(summary.len(), 1);
1094        assert!(summary.contains_key(&member_id));
1095        assert_eq!(summary.get(&member_id).unwrap().0, 1); // Version should be 1
1096    }
1097
1098    #[test]
1099    fn test_member_info_v1_delta() {
1100        let owner_signing_key = SigningKey::generate(&mut OsRng);
1101        let member_id1 = SigningKey::generate(&mut OsRng).verifying_key().into();
1102        let member_id2 = SigningKey::generate(&mut OsRng).verifying_key().into();
1103
1104        let member_info1 = create_test_member_info(member_id1);
1105        let member_info2 = create_test_member_info(member_id2);
1106
1107        let authorized_member_info1 = AuthorizedMemberInfo::new(member_info1, &owner_signing_key);
1108        let authorized_member_info2 = AuthorizedMemberInfo::new(member_info2, &owner_signing_key);
1109        // Capture member1's signature for the summary tiebreak (#411 round 4 B).
1110        let sig1 = authorized_member_info1.signature;
1111
1112        let mut member_info_v1 = MemberInfoV1::default();
1113        member_info_v1.member_info.push(authorized_member_info1);
1114        member_info_v1.member_info.push(authorized_member_info2);
1115
1116        let parent_state = ChatRoomStateV1::default();
1117        let parameters = ChatRoomParametersV1 {
1118            owner: owner_signing_key.verifying_key(),
1119        };
1120
1121        // Summary says the peer already holds member1 at (version 1, sig1), so
1122        // member1 does not outrank it and only member2 appears in the delta.
1123        let mut old_summary = BTreeMap::new();
1124        old_summary.insert(member_id1, member_info_rank(1, &sig1));
1125
1126        let delta = member_info_v1.delta(&parent_state, &parameters, &old_summary);
1127
1128        assert!(delta.is_some());
1129        let delta = delta.unwrap();
1130        assert_eq!(delta.len(), 1);
1131        assert_eq!(delta[0].member_info.member_id, member_id2);
1132    }
1133
1134    #[test]
1135    fn test_member_info_v1_apply_delta() {
1136        let owner_signing_key = SigningKey::generate(&mut OsRng);
1137        let owner_verifying_key = owner_signing_key.verifying_key();
1138        let owner_id = owner_verifying_key.into();
1139
1140        let member_signing_key = SigningKey::generate(&mut OsRng);
1141        let member_verifying_key = member_signing_key.verifying_key();
1142        let member_id = member_verifying_key.into();
1143
1144        let member_info = create_test_member_info(member_id);
1145        let authorized_member_info =
1146            AuthorizedMemberInfo::new_with_member_key(member_info, &member_signing_key);
1147
1148        let mut member_info_v1 = MemberInfoV1::default();
1149        let delta = vec![authorized_member_info.clone()];
1150
1151        let mut parent_state = ChatRoomStateV1::default();
1152        parent_state.members.members.push(AuthorizedMember {
1153            member: Member {
1154                owner_member_id: owner_id,
1155                invited_by: owner_id,
1156                member_vk: member_verifying_key,
1157            },
1158            signature: owner_signing_key
1159                .sign("TestUser".as_bytes())
1160                .to_bytes()
1161                .into(),
1162        });
1163
1164        let parameters = ChatRoomParametersV1 {
1165            owner: owner_verifying_key,
1166        };
1167
1168        // Test applying delta with a new member
1169        println!("Applying delta with a new member");
1170        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(delta));
1171        println!("Result: {:?}", result);
1172        assert!(result.is_ok(), "Failed to apply delta: {:?}", result.err());
1173        assert_eq!(member_info_v1.member_info.len(), 1);
1174        assert_eq!(member_info_v1.member_info[0], authorized_member_info);
1175
1176        // Test applying delta with an existing member (update)
1177        println!("Applying delta with an existing member (update)");
1178        let updated_member_info =
1179            MemberInfo::new_public(member_id, 2, "UpdatedNickname".to_string());
1180        let updated_authorized_member_info =
1181            AuthorizedMemberInfo::new_with_member_key(updated_member_info, &member_signing_key);
1182        let update_delta = vec![updated_authorized_member_info.clone()];
1183
1184        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(update_delta));
1185        println!("Result: {:?}", result);
1186        assert!(
1187            result.is_ok(),
1188            "Failed to apply update delta: {:?}",
1189            result.err()
1190        );
1191        assert_eq!(member_info_v1.member_info.len(), 1);
1192        assert_eq!(
1193            member_info_v1.member_info[0],
1194            updated_authorized_member_info
1195        );
1196
1197        // Test applying delta with a non-existent member (should succeed, entry silently dropped)
1198        println!("Applying delta with a non-existent member");
1199        let non_existent_member_id = SigningKey::generate(&mut OsRng).verifying_key().into();
1200        let non_existent_member_info = create_test_member_info(non_existent_member_id);
1201        let non_existent_authorized_member_info = AuthorizedMemberInfo::new_with_member_key(
1202            non_existent_member_info,
1203            &SigningKey::generate(&mut OsRng),
1204        );
1205        let non_existent_delta = vec![non_existent_authorized_member_info];
1206
1207        let prev_len = member_info_v1.member_info.len();
1208        let result =
1209            member_info_v1.apply_delta(&parent_state, &parameters, &Some(non_existent_delta));
1210        println!("Result: {:?}", result);
1211        assert!(
1212            result.is_ok(),
1213            "Non-existent member should be silently skipped"
1214        );
1215        assert_eq!(
1216            member_info_v1.member_info.len(),
1217            prev_len,
1218            "Entry should not be added"
1219        );
1220
1221        // Test applying delta with an older version (should not update)
1222        println!("Applying delta with an older version");
1223        let older_member_info = MemberInfo::new_public(member_id, 1, "TestUser".to_string());
1224        let older_authorized_member_info =
1225            AuthorizedMemberInfo::new_with_member_key(older_member_info, &member_signing_key);
1226        let older_delta = vec![older_authorized_member_info];
1227
1228        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(older_delta));
1229        println!("Result: {:?}", result);
1230        assert!(
1231            result.is_ok(),
1232            "Failed to apply older version delta: {:?}",
1233            result.err()
1234        );
1235        assert_eq!(member_info_v1.member_info.len(), 1);
1236        assert_eq!(member_info_v1.member_info[0].member_info.version, 2);
1237
1238        // Test applying delta with multiple members
1239        println!("Applying delta with multiple members");
1240        let new_member_signing_key = SigningKey::generate(&mut OsRng);
1241        let new_member_verifying_key = new_member_signing_key.verifying_key();
1242        let new_member_id = new_member_verifying_key.into();
1243        let new_member_info = create_test_member_info(new_member_id);
1244        let new_authorized_member_info =
1245            AuthorizedMemberInfo::new_with_member_key(new_member_info, &new_member_signing_key);
1246
1247        parent_state.members.members.push(AuthorizedMember {
1248            member: Member {
1249                owner_member_id: owner_id,
1250                invited_by: owner_id,
1251                member_vk: new_member_verifying_key,
1252            },
1253            signature: owner_signing_key
1254                .sign("NewTestUser".as_bytes())
1255                .to_bytes()
1256                .into(),
1257        });
1258
1259        let multi_delta = vec![
1260            updated_authorized_member_info.clone(),
1261            new_authorized_member_info.clone(),
1262        ];
1263
1264        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(multi_delta));
1265        println!("Result: {:?}", result);
1266        assert!(
1267            result.is_ok(),
1268            "Failed to apply multi-member delta: {:?}",
1269            result.err()
1270        );
1271        assert_eq!(member_info_v1.member_info.len(), 2);
1272        assert!(member_info_v1
1273            .member_info
1274            .contains(&updated_authorized_member_info));
1275        assert!(member_info_v1
1276            .member_info
1277            .contains(&new_authorized_member_info));
1278    }
1279
1280    #[test]
1281    fn test_authorized_member_info_new_and_verify() {
1282        let owner_signing_key = SigningKey::generate(&mut OsRng);
1283        let member_id = SigningKey::generate(&mut OsRng).verifying_key().into();
1284        let member_info = create_test_member_info(member_id);
1285
1286        let authorized_member_info =
1287            AuthorizedMemberInfo::new(member_info.clone(), &owner_signing_key);
1288
1289        let parameters = ChatRoomParametersV1 {
1290            owner: owner_signing_key.verifying_key(),
1291        };
1292
1293        assert!(authorized_member_info.verify_signature(&parameters).is_ok());
1294
1295        // Test with wrong key
1296        let wrong_key = SigningKey::generate(&mut OsRng).verifying_key();
1297        let wrong_parameters = ChatRoomParametersV1 { owner: wrong_key };
1298        assert!(authorized_member_info
1299            .verify_signature(&wrong_parameters)
1300            .is_err());
1301    }
1302
1303    #[test]
1304    fn test_member_info_v1_delta_scenarios() {
1305        let owner_signing_key = SigningKey::generate(&mut OsRng);
1306        let owner_verifying_key = owner_signing_key.verifying_key();
1307
1308        let mut member_info_v1 = MemberInfoV1::default();
1309        let parent_state = ChatRoomStateV1::default();
1310        let parameters = ChatRoomParametersV1 {
1311            owner: owner_verifying_key,
1312        };
1313
1314        // Generate 5 member infos
1315        let member_infos: Vec<AuthorizedMemberInfo> = (0..5)
1316            .map(|_| {
1317                let member_id = SigningKey::generate(&mut OsRng).verifying_key().into();
1318                let member_info = create_test_member_info(member_id);
1319                AuthorizedMemberInfo::new(member_info, &owner_signing_key)
1320            })
1321            .collect();
1322
1323        // Test when all members are new
1324        member_info_v1.member_info = member_infos.clone();
1325        let delta = member_info_v1.delta(&parent_state, &parameters, &BTreeMap::new());
1326        assert_eq!(delta.unwrap().len(), 5);
1327
1328        // Test when all members are old with the same (version, digest) —
1329        // nothing outranks the summary, so the delta is empty (#411 round 4 B).
1330        let old_summary: BTreeMap<MemberId, (u32, SigDigest)> = member_infos
1331            .iter()
1332            .map(|info| {
1333                (
1334                    info.member_info.member_id,
1335                    member_info_rank(info.member_info.version, &info.signature),
1336                )
1337            })
1338            .collect();
1339        let delta = member_info_v1.delta(&parent_state, &parameters, &old_summary);
1340        assert!(delta.is_none());
1341
1342        // Test with a mix of new and old members
1343        let mut old_summary = BTreeMap::new();
1344        old_summary.insert(
1345            member_infos[0].member_info.member_id,
1346            member_info_rank(1, &member_infos[0].signature),
1347        );
1348        old_summary.insert(
1349            member_infos[1].member_info.member_id,
1350            member_info_rank(1, &member_infos[1].signature),
1351        );
1352        let delta = member_info_v1.delta(&parent_state, &parameters, &old_summary);
1353        assert_eq!(delta.unwrap().len(), 3);
1354
1355        // Test with updated version
1356        let mut updated_member_info = member_infos[0].clone();
1357        updated_member_info.member_info.version = 2;
1358        member_info_v1.member_info[0] = updated_member_info;
1359
1360        let delta = member_info_v1.delta(&parent_state, &parameters, &old_summary);
1361        assert_eq!(delta.unwrap().len(), 4); // 3 new members + 1 updated member
1362    }
1363
1364    #[test]
1365    fn test_member_info_version_handling() {
1366        let owner_signing_key = SigningKey::generate(&mut OsRng);
1367        let owner_verifying_key = owner_signing_key.verifying_key();
1368        let owner_id = owner_verifying_key.into();
1369
1370        // Create a member
1371        let member_signing_key = SigningKey::generate(&mut OsRng);
1372        let member_verifying_key = member_signing_key.verifying_key();
1373        let member_id = member_verifying_key.into();
1374
1375        // Create initial member info with version 1
1376        let member_info_v1 = create_test_member_info(member_id);
1377        let authorized_member_info_v1 =
1378            AuthorizedMemberInfo::new_with_member_key(member_info_v1, &member_signing_key);
1379
1380        // Create updated member info with version 2
1381        let member_info_v2 = MemberInfo::new_public(member_id, 2, "UpdatedNickname".to_string());
1382        let authorized_member_info_v2 =
1383            AuthorizedMemberInfo::new_with_member_key(member_info_v2, &member_signing_key);
1384
1385        // Set up state with version 1
1386        let mut member_info_state = MemberInfoV1::default();
1387        member_info_state
1388            .member_info
1389            .push(authorized_member_info_v1.clone());
1390
1391        // Create parent state with the member
1392        let mut parent_state = ChatRoomStateV1::default();
1393        let member = Member {
1394            owner_member_id: owner_id,
1395            invited_by: owner_id,
1396            member_vk: member_verifying_key,
1397        };
1398        let authorized_member = AuthorizedMember::new(member, &owner_signing_key);
1399        parent_state.members.members.push(authorized_member);
1400
1401        let parameters = ChatRoomParametersV1 {
1402            owner: owner_verifying_key,
1403        };
1404
1405        // Create summary with version 1
1406        let summary = member_info_state.summarize(&parent_state, &parameters);
1407        assert_eq!(summary.get(&member_id).unwrap().0, 1);
1408
1409        // Create delta with version 2
1410        let mut updated_state = MemberInfoV1::default();
1411        updated_state
1412            .member_info
1413            .push(authorized_member_info_v2.clone());
1414
1415        let delta = updated_state.delta(&parent_state, &parameters, &summary);
1416        assert!(delta.is_some());
1417        assert_eq!(delta.as_ref().unwrap().len(), 1);
1418        assert_eq!(delta.as_ref().unwrap()[0].member_info.version, 2);
1419
1420        // Apply delta and verify version is updated
1421        member_info_state
1422            .apply_delta(&parent_state, &parameters, &delta)
1423            .unwrap();
1424        assert_eq!(member_info_state.member_info.len(), 1);
1425        assert_eq!(member_info_state.member_info[0].member_info.version, 2);
1426        assert_eq!(
1427            member_info_state.member_info[0]
1428                .member_info
1429                .preferred_nickname,
1430            SealedBytes::public("UpdatedNickname".to_string().into_bytes())
1431        );
1432    }
1433
1434    #[test]
1435    fn test_room_owner_member_info() {
1436        let owner_signing_key = SigningKey::generate(&mut OsRng);
1437        let owner_verifying_key = owner_signing_key.verifying_key();
1438        let owner_id = owner_verifying_key.into();
1439
1440        let owner_member_info = create_test_member_info(owner_id);
1441        let authorized_owner_info =
1442            AuthorizedMemberInfo::new(owner_member_info, &owner_signing_key);
1443
1444        let mut member_info_v1 = MemberInfoV1::default();
1445        member_info_v1.member_info.push(authorized_owner_info);
1446
1447        let mut parent_state = ChatRoomStateV1::default();
1448        parent_state.members.members.push(AuthorizedMember {
1449            member: Member {
1450                owner_member_id: owner_id,
1451                invited_by: owner_id,
1452                member_vk: owner_verifying_key,
1453            },
1454            signature: owner_signing_key
1455                .sign("TestOwner".as_bytes())
1456                .to_bytes()
1457                .into(),
1458        });
1459
1460        let parameters = ChatRoomParametersV1 {
1461            owner: owner_verifying_key,
1462        };
1463
1464        let result = member_info_v1.verify(&parent_state, &parameters);
1465        assert!(
1466            result.is_ok(),
1467            "Room owner should be allowed to have member info: {:?}",
1468            result
1469        );
1470    }
1471
1472    #[test]
1473    fn test_member_info_retention() {
1474        let owner_signing_key = SigningKey::generate(&mut OsRng);
1475        let owner_verifying_key = owner_signing_key.verifying_key();
1476        let owner_id = owner_verifying_key.into();
1477
1478        // Create owner's member info
1479        let owner_member_info = create_test_member_info(owner_id);
1480        let authorized_owner_info =
1481            AuthorizedMemberInfo::new(owner_member_info, &owner_signing_key);
1482
1483        // Create regular member's info
1484        let member_signing_key = SigningKey::generate(&mut OsRng);
1485        let member_verifying_key = member_signing_key.verifying_key();
1486        let member_id = member_verifying_key.into();
1487        let member_info = create_test_member_info(member_id);
1488        let authorized_member_info =
1489            AuthorizedMemberInfo::new_with_member_key(member_info, &member_signing_key);
1490
1491        // Set up MemberInfoV1 with both owner and member info
1492        let mut member_info_v1 = MemberInfoV1::default();
1493        member_info_v1
1494            .member_info
1495            .push(authorized_owner_info.clone());
1496        member_info_v1
1497            .member_info
1498            .push(authorized_member_info.clone());
1499
1500        // Set up parent state with only the regular member
1501        let mut parent_state = ChatRoomStateV1::default();
1502        parent_state.members.members.push(AuthorizedMember {
1503            member: Member {
1504                owner_member_id: owner_id,
1505                invited_by: owner_id,
1506                member_vk: member_verifying_key,
1507            },
1508            signature: owner_signing_key
1509                .sign("TestMember".as_bytes())
1510                .to_bytes()
1511                .into(),
1512        });
1513
1514        let parameters = ChatRoomParametersV1 {
1515            owner: owner_verifying_key,
1516        };
1517
1518        // Apply an empty delta to trigger retention logic
1519        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(vec![]));
1520        assert!(result.is_ok(), "Failed to apply delta: {:?}", result.err());
1521
1522        // Verify that owner's info is retained even though not in members list
1523        assert!(
1524            member_info_v1
1525                .member_info
1526                .iter()
1527                .any(|info| info.member_info.member_id == owner_id),
1528            "Owner's member info should be retained"
1529        );
1530
1531        // Remove the regular member from parent state
1532        parent_state.members.members.clear();
1533
1534        // Apply another empty delta
1535        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(vec![]));
1536        assert!(
1537            result.is_ok(),
1538            "Failed to apply second delta: {:?}",
1539            result.err()
1540        );
1541
1542        // Verify that only owner's info remains
1543        assert_eq!(
1544            member_info_v1.member_info.len(),
1545            1,
1546            "Should only contain owner's info"
1547        );
1548        assert_eq!(
1549            member_info_v1.member_info[0].member_info.member_id, owner_id,
1550            "Remaining info should be owner's"
1551        );
1552    }
1553
1554    /// Regression test: apply_delta should succeed when the delta contains
1555    /// member_info for a member that was simultaneously removed from
1556    /// parent_state.members (e.g. ban or max_members eviction).
1557    #[test]
1558    fn test_apply_delta_with_removed_member_info() {
1559        let owner_signing_key = SigningKey::generate(&mut OsRng);
1560        let owner_verifying_key = owner_signing_key.verifying_key();
1561        let owner_id = owner_verifying_key.into();
1562
1563        let member_signing_key = SigningKey::generate(&mut OsRng);
1564        let member_verifying_key = member_signing_key.verifying_key();
1565        let member_id = member_verifying_key.into();
1566
1567        // Start with the member present in both member_info and members list
1568        let member_info = create_test_member_info(member_id);
1569        let authorized_member_info =
1570            AuthorizedMemberInfo::new_with_member_key(member_info, &member_signing_key);
1571
1572        let mut member_info_v1 = MemberInfoV1 {
1573            member_info: vec![authorized_member_info.clone()],
1574        };
1575
1576        // Parent state with member REMOVED (simulates ban/max_members)
1577        let parent_state = ChatRoomStateV1::default();
1578        let parameters = ChatRoomParametersV1 {
1579            owner: owner_verifying_key,
1580        };
1581
1582        // Delta includes member_info for the now-removed member
1583        let updated_info = MemberInfo::new_public(member_id, 2, "NewNick".to_string());
1584        let updated_authorized =
1585            AuthorizedMemberInfo::new_with_member_key(updated_info, &member_signing_key);
1586        let delta = vec![updated_authorized];
1587
1588        // Previously this would error; now it should succeed
1589        let result = member_info_v1.apply_delta(&parent_state, &parameters, &Some(delta));
1590        assert!(
1591            result.is_ok(),
1592            "apply_delta should skip removed member's info, got: {:?}",
1593            result.err()
1594        );
1595
1596        // The removed member's info should be cleaned up by retention
1597        assert!(
1598            !member_info_v1
1599                .member_info
1600                .iter()
1601                .any(|info| info.member_info.member_id == member_id),
1602            "Removed member's info should be pruned"
1603        );
1604
1605        // Owner info (if any) should be unaffected
1606        let owner_info = create_test_member_info(owner_id);
1607        let authorized_owner = AuthorizedMemberInfo::new(owner_info, &owner_signing_key);
1608        member_info_v1.member_info.push(authorized_owner.clone());
1609
1610        let result = member_info_v1.apply_delta(&parent_state, &parameters, &None);
1611        assert!(result.is_ok());
1612        assert_eq!(member_info_v1.member_info.len(), 1);
1613        assert_eq!(
1614            member_info_v1.member_info[0].member_info.member_id,
1615            owner_id
1616        );
1617    }
1618
1619    /// #411 round 7 / Codex P1 #3: a state carrying TWO validly-signed
1620    /// `member_info` records for the same member (a `grant` at v1 and a `revoke`
1621    /// at v2) must READ consistently. `verify` MUST still accept it
1622    /// (migration-safety), and BOTH `deputies_of` (enforcement) and `summarize`
1623    /// (the anti-entropy advertised value) must select the highest-rank (v2 =
1624    /// revoke) record, regardless of vector order — so peers with different
1625    /// duplicate sets converge on both enforcement and the summary.
1626    #[test]
1627    fn duplicate_member_info_reads_canonicalize_to_highest_rank() {
1628        let owner_signing_key = SigningKey::generate(&mut OsRng);
1629        let owner_verifying_key = owner_signing_key.verifying_key();
1630        let owner_id = owner_verifying_key.into();
1631
1632        let member_signing_key = SigningKey::generate(&mut OsRng);
1633        let member_verifying_key = member_signing_key.verifying_key();
1634        let member_id = member_verifying_key.into();
1635
1636        let deputy = SigningKey::generate(&mut OsRng).verifying_key().into();
1637
1638        // grant @ v1: member deputizes `deputy`.
1639        let mut grant_mi = MemberInfo::new_public(member_id, 1, "nick".to_string());
1640        grant_mi.deputies = vec![deputy];
1641        let grant = AuthorizedMemberInfo::new_with_member_key(grant_mi, &member_signing_key);
1642
1643        // revoke @ v2 (higher rank): member clears their deputies.
1644        let mut revoke_mi = MemberInfo::new_public(member_id, 2, "nick".to_string());
1645        revoke_mi.deputies = vec![];
1646        let revoke = AuthorizedMemberInfo::new_with_member_key(revoke_mi, &member_signing_key);
1647        let revoke_summary_value = member_info_rank(2u32, &revoke.signature);
1648
1649        let mut parent_state = ChatRoomStateV1::default();
1650        parent_state.members.members.push(AuthorizedMember::new(
1651            Member {
1652                owner_member_id: owner_id,
1653                invited_by: owner_id,
1654                member_vk: member_verifying_key,
1655            },
1656            &owner_signing_key,
1657        ));
1658        let parameters = ChatRoomParametersV1 {
1659            owner: owner_verifying_key,
1660        };
1661
1662        for (label, entries) in [
1663            ("grant-then-revoke", vec![grant.clone(), revoke.clone()]),
1664            ("revoke-then-grant", vec![revoke.clone(), grant.clone()]),
1665        ] {
1666            let state = MemberInfoV1 {
1667                member_info: entries,
1668            };
1669
1670            // Migration-safety: `verify` accepts the duplicate state.
1671            assert!(
1672                state.verify(&parent_state, &parameters).is_ok(),
1673                "verify must ACCEPT a duplicate-member_info state ({label})"
1674            );
1675
1676            // Enforcement reads the highest-rank (revoke @ v2 → no deputies).
1677            assert_eq!(
1678                state.deputies_of(member_id),
1679                &[] as &[MemberId],
1680                "deputies_of must return the v2 (revoke) result ({label})"
1681            );
1682
1683            // Anti-entropy advertises the highest-rank (version, digest).
1684            let summary = state.summarize(&parent_state, &parameters);
1685            assert_eq!(
1686                summary.get(&member_id).copied(),
1687                Some(revoke_summary_value),
1688                "summarize must advertise the v2 (revoke) (version, digest) ({label})"
1689            );
1690        }
1691    }
1692
1693    /// #411 round 7 / Codex P1 #3: when two records for one member are present,
1694    /// `deputies_of` must return the HIGHEST-rank record's deputies (higher
1695    /// version, else greater signature digest) — the same winner `apply_delta` /
1696    /// `summarize` converge on — regardless of vector order. A bare `.find()`
1697    /// (first) could disagree with the converged state.
1698    #[test]
1699    fn deputies_of_picks_highest_rank_record() {
1700        let member_signing_key = SigningKey::generate(&mut OsRng);
1701        let member_id = member_signing_key.verifying_key().into();
1702
1703        let deputy_x = SigningKey::generate(&mut OsRng).verifying_key().into();
1704        let deputy_y = SigningKey::generate(&mut OsRng).verifying_key().into();
1705
1706        // Lower-version record lists deputy_x; higher-version lists deputy_y.
1707        let mut mi_v1 = MemberInfo::new_public(member_id, 1, "nick".to_string());
1708        mi_v1.deputies = vec![deputy_x];
1709        let low = AuthorizedMemberInfo::new_with_member_key(mi_v1, &member_signing_key);
1710
1711        let mut mi_v2 = MemberInfo::new_public(member_id, 2, "nick".to_string());
1712        mi_v2.deputies = vec![deputy_y];
1713        let high = AuthorizedMemberInfo::new_with_member_key(mi_v2, &member_signing_key);
1714
1715        // Put the LOWER-rank record FIRST so a naive `.find()` would pick it.
1716        let member_info_v1 = MemberInfoV1 {
1717            member_info: vec![low, high],
1718        };
1719
1720        assert_eq!(
1721            member_info_v1.deputies_of(member_id),
1722            &[deputy_y],
1723            "deputies_of must return the highest-version record's deputies"
1724        );
1725
1726        // Also robust to the reverse vector order.
1727        let mut mi_v1b = MemberInfo::new_public(member_id, 1, "nick".to_string());
1728        mi_v1b.deputies = vec![deputy_x];
1729        let low_b = AuthorizedMemberInfo::new_with_member_key(mi_v1b, &member_signing_key);
1730        let mut mi_v2b = MemberInfo::new_public(member_id, 2, "nick".to_string());
1731        mi_v2b.deputies = vec![deputy_y];
1732        let high_b = AuthorizedMemberInfo::new_with_member_key(mi_v2b, &member_signing_key);
1733        let reversed = MemberInfoV1 {
1734            member_info: vec![high_b, low_b],
1735        };
1736        assert_eq!(
1737            reversed.deputies_of(member_id),
1738            &[deputy_y],
1739            "deputies_of result must be independent of vector order"
1740        );
1741    }
1742
1743    /// #411 round 8 item A/C: `canonical` selects the highest-rank record and
1744    /// `dedup_to_canonical` collapses duplicates to it (owner kept), independent
1745    /// of vector order. This is the shared selector EVERY by-id reader/writer
1746    /// routes through, plus the post_apply_cleanup dedup.
1747    #[test]
1748    fn canonical_and_dedup_to_canonical_pick_highest_rank() {
1749        let owner_sk = SigningKey::generate(&mut OsRng);
1750        let owner_id: MemberId = owner_sk.verifying_key().into();
1751
1752        let m_sk = SigningKey::generate(&mut OsRng);
1753        let m_id: MemberId = m_sk.verifying_key().into();
1754        let n_sk = SigningKey::generate(&mut OsRng);
1755        let n_id: MemberId = n_sk.verifying_key().into();
1756
1757        let dep = SigningKey::generate(&mut OsRng).verifying_key().into();
1758        // M: grant @ v1 (deputies=[dep]) and revoke @ v2 (deputies=[]).
1759        let mut g = MemberInfo::new_public(m_id, 1, "nick".to_string());
1760        g.deputies = vec![dep];
1761        let grant = AuthorizedMemberInfo::new_with_member_key(g, &m_sk);
1762        let mut r = MemberInfo::new_public(m_id, 2, "nick".to_string());
1763        r.deputies = vec![];
1764        let revoke = AuthorizedMemberInfo::new_with_member_key(r, &m_sk);
1765        // N: single record. Owner: single record.
1766        let n_info = AuthorizedMemberInfo::new_with_member_key(
1767            MemberInfo::new_public(n_id, 3, "n".to_string()),
1768            &n_sk,
1769        );
1770        let owner_info = AuthorizedMemberInfo::new(
1771            MemberInfo::new_public(owner_id, 1, "o".to_string()),
1772            &owner_sk,
1773        );
1774
1775        for order in [
1776            vec![
1777                grant.clone(),
1778                revoke.clone(),
1779                n_info.clone(),
1780                owner_info.clone(),
1781            ],
1782            vec![
1783                owner_info.clone(),
1784                n_info.clone(),
1785                revoke.clone(),
1786                grant.clone(),
1787            ],
1788        ] {
1789            let mut mi = MemberInfoV1 { member_info: order };
1790
1791            // canonical picks the v2 revoke for M.
1792            assert_eq!(
1793                mi.canonical(m_id).map(|c| c.member_info.version),
1794                Some(2),
1795                "canonical(M) must be the v2 revoke"
1796            );
1797            assert_eq!(mi.canonical(n_id).map(|c| c.member_info.version), Some(3));
1798            assert_eq!(
1799                mi.canonical(owner_id).map(|c| c.member_info.version),
1800                Some(1)
1801            );
1802            assert!(mi
1803                .canonical(SigningKey::generate(&mut OsRng).verifying_key().into())
1804                .is_none());
1805
1806            // dedup collapses M's duplicate, keeps owner + N, one record each.
1807            mi.dedup_to_canonical();
1808            assert_eq!(mi.member_info.len(), 3, "exactly one record per member_id");
1809            let m_rec = mi
1810                .member_info
1811                .iter()
1812                .find(|i| i.member_info.member_id == m_id)
1813                .unwrap();
1814            assert_eq!(
1815                m_rec.member_info.version, 2,
1816                "M's kept record is the revoke"
1817            );
1818            assert!(
1819                m_rec.member_info.deputies.is_empty(),
1820                "revoke has no deputies"
1821            );
1822            assert!(mi
1823                .member_info
1824                .iter()
1825                .any(|i| i.member_info.member_id == owner_id));
1826            // Idempotent.
1827            let before = mi.clone();
1828            mi.dedup_to_canonical();
1829            assert_eq!(before, mi, "dedup_to_canonical is idempotent");
1830        }
1831    }
1832
1833    /// #411 round 8 Task 2: `apply_delta` collapses a PRE-EXISTING duplicate
1834    /// state (seeded by a full-state PUT/GET, which `verify` accepts) to the
1835    /// single canonical record per member on the WRITE path — not only later in
1836    /// `post_apply_cleanup`. Even an empty delta triggers it.
1837    #[test]
1838    fn apply_delta_dedups_preexisting_duplicates_to_canonical() {
1839        let owner_sk = SigningKey::generate(&mut OsRng);
1840        let owner_vk = owner_sk.verifying_key();
1841        let owner_id = owner_vk.into();
1842
1843        let m_sk = SigningKey::generate(&mut OsRng);
1844        let m_vk = m_sk.verifying_key();
1845        let m_id: MemberId = m_vk.into();
1846
1847        let dep = SigningKey::generate(&mut OsRng).verifying_key().into();
1848        let mut g = MemberInfo::new_public(m_id, 1, "n".to_string());
1849        g.deputies = vec![dep];
1850        let grant = AuthorizedMemberInfo::new_with_member_key(g, &m_sk);
1851        let mut r = MemberInfo::new_public(m_id, 2, "n".to_string());
1852        r.deputies = vec![];
1853        let revoke = AuthorizedMemberInfo::new_with_member_key(r, &m_sk);
1854
1855        // Pre-existing duplicate (grant FIRST so a naive first-match would keep it).
1856        let mut mi = MemberInfoV1 {
1857            member_info: vec![grant, revoke],
1858        };
1859
1860        let mut parent_state = ChatRoomStateV1::default();
1861        parent_state.members.members.push(AuthorizedMember::new(
1862            Member {
1863                owner_member_id: owner_id,
1864                invited_by: owner_id,
1865                member_vk: m_vk,
1866            },
1867            &owner_sk,
1868        ));
1869        let params = ChatRoomParametersV1 { owner: owner_vk };
1870
1871        // Applying even an empty delta collapses the duplicate on the write path.
1872        mi.apply_delta(&parent_state, &params, &None).unwrap();
1873
1874        let recs: Vec<_> = mi
1875            .member_info
1876            .iter()
1877            .filter(|i| i.member_info.member_id == m_id)
1878            .collect();
1879        assert_eq!(
1880            recs.len(),
1881            1,
1882            "apply_delta must collapse the pre-existing duplicate on the write path"
1883        );
1884        assert_eq!(recs[0].member_info.version, 2, "canonical (revoke) kept");
1885        assert!(recs[0].member_info.deputies.is_empty());
1886    }
1887}