Skip to main content

vector_core/community/v2/
guestbook.rs

1//! CORD-02 §5 Guestbook Plane — membership motion, coalesced flat.
2//!
3//! One Stream per Community (community_root-keyed,
4//! [`super::derive::guestbook_group_key`]), carrying ONLY membership motion:
5//! self-signed Joins/Leaves (3306), authorized Kicks (3309), and
6//! refounder-signed post-Refounding Snapshots (3312) — never messages, never
7//! authority (a Ban lives on the Control Plane). The plane is *off-consensus*:
8//! nothing in Control or Chat depends on it, so it loads last and can lag
9//! without harm.
10//!
11//! Everything here is PURE — no DB, no network, no clock reads. The +1h
12//! forward-clock rule takes `now_ms` as a parameter, and the two authority
13//! questions arrive from the caller's Control Plane fold: `can_kick` (the
14//! KICK bit + strict outrank) and `snapshot_authority` (the npub whose
15//! Refounding minted the epoch being folded).
16//!
17//! Guestbook seals are ENCRYPTED (20013) by spec: a plaintext seal would make
18//! a member's signed membership record liftable as a standalone public
19//! artifact, so [`parse_guestbook_event`] rejects the plaintext form outright.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
24
25use super::super::edition::AuthorityCitation;
26use super::derive::GroupKey;
27use super::kind;
28use super::stream::{self, OpenedStream, SealForm, StreamError};
29
30/// Entries dated further than this ahead of the receiver's clock are dropped
31/// outright (CORD-02 §5) — ample for deep clock skew, and the deterrent
32/// against squatting "latest" with a forged future date.
33pub const MAX_FUTURE_MS: u64 = 3_600_000;
34
35/// Snapshot chunk size: 400 members per event (CORD-02 §5). 400 hex pubkeys of
36/// JSON is ~27 KB — comfortably inside the NIP-44 65,535-byte cap at every
37/// nesting layer, with headroom for the envelope.
38pub const SNAPSHOT_CHUNK: usize = 400;
39
40const TAG_INVITE: &str = "invite";
41const TAG_TARGET: &str = "p";
42const TAG_SNAP: &str = "snap";
43
44const VERB_JOIN: &str = "join";
45const VERB_LEAVE: &str = "leave";
46
47/// Errors from the guestbook layer (envelope errors ride inside).
48#[derive(Debug)]
49pub enum GuestbookError {
50    Stream(StreamError),
51    /// The rumor kind isn't a guestbook kind (3306 / 3309 / 3312).
52    NotGuestbook(u16),
53    /// A guestbook rumor arrived in a plaintext seal — CORD-02 §5 requires the
54    /// encrypted form (a plaintext seal is a liftable, publicly verifiable
55    /// membership record), so a strict reader drops it.
56    NotEncryptedSealed,
57    /// A 3306's content isn't exactly `"join"` or `"leave"` — the verb IS the
58    /// state, so anything else is malformed, never interpreted.
59    BadVerb,
60    MissingTag(&'static str),
61    /// A state-bearing tag appears more than once — ambiguous, rejected.
62    DuplicateTag(&'static str),
63    /// A state-bearing tag is present but unparseable (bad target hex, bad
64    /// snap id / chunk indices).
65    BadTag(&'static str),
66    /// Snapshot content isn't a JSON array.
67    BadSnapshotContent,
68}
69
70impl std::fmt::Display for GuestbookError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            GuestbookError::Stream(e) => write!(f, "stream: {e}"),
74            GuestbookError::NotGuestbook(k) => write!(f, "rumor kind {k} is not a guestbook event"),
75            GuestbookError::NotEncryptedSealed => write!(f, "guestbook events must ride an encrypted seal"),
76            GuestbookError::BadVerb => write!(f, "3306 content is not exactly join/leave"),
77            GuestbookError::MissingTag(t) => write!(f, "missing guestbook tag: {t}"),
78            GuestbookError::DuplicateTag(t) => write!(f, "duplicate guestbook tag: {t}"),
79            GuestbookError::BadTag(t) => write!(f, "unparseable guestbook tag: {t}"),
80            GuestbookError::BadSnapshotContent => write!(f, "snapshot content is not a JSON array"),
81        }
82    }
83}
84
85impl std::error::Error for GuestbookError {}
86
87impl From<StreamError> for GuestbookError {
88    fn from(e: StreamError) -> Self {
89        GuestbookError::Stream(e)
90    }
91}
92
93// ── Rumor builders ───────────────────────────────────────────────────────────
94
95/// A self-signed Join, optionally echoing the invite attribution from the
96/// bundle that admitted the author (`["invite", creator, label]`, CORD-05 §1).
97pub fn build_join_rumor(author: PublicKey, invite_attribution: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent {
98    let mut tags = Vec::new();
99    if let Some((creator, label)) = invite_attribution {
100        tags.push(Tag::custom(
101            TAG_INVITE,
102            [creator.to_string(), label.to_string()],
103        ));
104    }
105    stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_JOIN, tags, at_ms)
106}
107
108/// A self-signed Leave.
109pub fn build_leave_rumor(author: PublicKey, at_ms: u64) -> UnsignedEvent {
110    stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_LEAVE, vec![], at_ms)
111}
112
113/// An admin-signed Kick naming its target and citing the Grant it acts under
114/// (the `vac`, CORD-04 §5) — absent when the owner acts (supreme, no grant to
115/// cite). Whether it's *honored* is the reader's call ([`coalesce`]'s
116/// `can_kick`), never the writer's.
117pub fn build_kick_rumor(
118    admin: PublicKey,
119    target: PublicKey,
120    citation: Option<&AuthorityCitation>,
121    at_ms: u64,
122) -> UnsignedEvent {
123    let mut tags = vec![Tag::public_key(target)];
124    if let Some(c) = citation {
125        tags.push(c.to_tag());
126    }
127    stream::build_rumor_ms(kind::KICK, admin, "", tags, at_ms)
128}
129
130/// Refounder-signed snapshot rumors seeding a new epoch's Guestbook: present
131/// members only, chunked at [`SNAPSHOT_CHUNK`], every chunk carrying
132/// `["snap", <id>, <i>, <n>]` (1-based) and ONE shared timestamp — the
133/// one-id-one-time invariant is what lets readers reject torn chunk sets.
134/// No survivors still yields one empty chunk, so the Refounding's guestbook
135/// step is observable either way.
136pub fn build_snapshot_rumors(
137    refounder: PublicKey,
138    members: &[PublicKey],
139    snapshot_id: [u8; 32],
140    at_ms: u64,
141) -> Vec<UnsignedEvent> {
142    let id_hex = crate::simd::hex::bytes_to_hex_32(&snapshot_id);
143    let chunks: Vec<&[PublicKey]> = if members.is_empty() {
144        vec![&[]]
145    } else {
146        members.chunks(SNAPSHOT_CHUNK).collect()
147    };
148    let n = chunks.len();
149    chunks
150        .iter()
151        .enumerate()
152        .map(|(idx, chunk)| {
153            let hexes: Vec<String> = chunk.iter().map(|p| p.to_hex()).collect();
154            let content = serde_json::to_string(&hexes).expect("a string array always serializes");
155            let tags = vec![Tag::custom(
156                TAG_SNAP,
157                [id_hex.clone(), (idx + 1).to_string(), n.to_string()],
158            )];
159            stream::build_rumor_ms(kind::SNAPSHOT, refounder, &content, tags, at_ms)
160        })
161        .collect()
162}
163
164/// Seal a guestbook rumor (encrypted form) into a wrap at `guestbook_pk`.
165/// Local-keys convenience; bunker accounts use [`stream::seal_content`] +
166/// their remote signer + [`stream::wrap_seal`] for identical wire output.
167pub fn seal_guestbook_rumor(
168    rumor: &UnsignedEvent,
169    group: &GroupKey,
170    author_keys: &Keys,
171    wrap_at: Timestamp,
172) -> Result<(Event, Keys), GuestbookError> {
173    let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
174    Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
175}
176
177/// Signer-driven twin of [`seal_guestbook_rumor`] for bunker / NIP-55 accounts:
178/// the encrypted seal signs through a [`VectorSigner`]. `author` is the identity
179/// the signer signs as (must equal `my_public_key()`). Wire-identical output.
180pub async fn seal_guestbook_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
181    signer: &S,
182    author: PublicKey,
183    rumor: &UnsignedEvent,
184    group: &GroupKey,
185    wrap_at: Timestamp,
186) -> Result<(Event, Keys), GuestbookError> {
187    Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, stream::KIND_WRAP, wrap_at, &[]).await?)
188}
189
190// ── Parse ────────────────────────────────────────────────────────────────────
191
192/// One parsed guestbook event: the entry plus the identity the coalesce
193/// tie-break runs on — the INNER rumor id, never the wrap's (a wrap id differs
194/// per re-wrap, and two clients holding different wraps of one rumor would
195/// fork on ties).
196#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
197pub struct GuestbookEvent {
198    /// The verified inner rumor id ([`OpenedStream::rumor_id`]).
199    pub rumor_id: [u8; 32],
200    pub entry: GuestbookEntry,
201    /// The root epoch this client held when it read the event — which plane it
202    /// came from. `None` for rows stored before stamping existed; the next
203    /// re-read stamps them.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub epoch: Option<u64>,
206    /// Unix seconds when THIS client first stored the event — an arrival time the
207    /// author cannot write. `at_ms` inside a Join is author-claimed, so a raider can
208    /// backdate it under any window; what we watched arrive, they cannot. Only
209    /// meaningful against a community whose guestbook we were already following
210    /// (see `guestbook_observed_since`) — on a fresh client every event is "new".
211    /// `None` for rows stored before stamping existed.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub observed_at: Option<u64>,
214}
215
216/// The typed guestbook entries (CORD-02 §5). Authors (`member` / `actor` /
217/// `refounder`) are the seal-verified real keys, proven by
218/// [`stream::open_wrap`] before parsing ever starts.
219#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub enum GuestbookEntry {
221    Join {
222        member: PublicKey,
223        /// Invite attribution echoed from the bundle: `(creator hex, label)`.
224        /// Advisory metadata, carried verbatim — never validated here.
225        invited_by: Option<(String, String)>,
226        at_ms: u64,
227    },
228    Leave {
229        member: PublicKey,
230        at_ms: u64,
231    },
232    Kick {
233        actor: PublicKey,
234        target: PublicKey,
235        /// The Grant the actor claims to act under; `None` covers both the
236        /// owner (no grant to cite) and a corrupt `vac` — the verifier treats
237        /// either as uncited, never trusting a malformed citation.
238        citation: Option<AuthorityCitation>,
239        at_ms: u64,
240    },
241    Snapshot {
242        refounder: PublicKey,
243        members: Vec<PublicKey>,
244        snapshot_id: [u8; 32],
245        /// `(i, n)` — 1-based chunk index over the chunk count.
246        chunk: (u32, u32),
247        at_ms: u64,
248    },
249}
250
251impl GuestbookEntry {
252    /// The entry's millisecond event time (CORD-02 §4).
253    pub fn at_ms(&self) -> u64 {
254        match self {
255            GuestbookEntry::Join { at_ms, .. }
256            | GuestbookEntry::Leave { at_ms, .. }
257            | GuestbookEntry::Kick { at_ms, .. }
258            | GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
259        }
260    }
261}
262
263/// Parse a guestbook event from an ALREADY-VERIFIED [`OpenedStream`] (one
264/// produced by [`stream::open_wrap`], which proved the seal signature, the
265/// author binding, the rumor id, and the strict `ms`). Strict on the seal
266/// form: guestbook rumors MUST ride encrypted seals (CORD-02 §5).
267pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
268    if opened.seal_form != SealForm::Encrypted {
269        return Err(GuestbookError::NotEncryptedSealed);
270    }
271    let rumor = &opened.rumor;
272    let at_ms = opened.at_ms;
273
274    let entry = match rumor.kind.as_u16() {
275        kind::JOIN_LEAVE => match rumor.content.as_str() {
276            VERB_JOIN => {
277                let invited_by = rumor.tags.iter().find_map(|t| {
278                    let s = t.as_slice();
279                    (s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
280                });
281                GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
282            }
283            VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
284            _ => return Err(GuestbookError::BadVerb),
285        },
286        kind::KICK => {
287            // The target must come from a UNIQUE p tag: a second one makes
288            // "who was kicked" pick-your-favorite — reject, never choose.
289            let mut target: Option<PublicKey> = None;
290            for t in rumor.tags.iter() {
291                let s = t.as_slice();
292                if s.len() >= 2 && s[0] == TAG_TARGET {
293                    if target.is_some() {
294                        return Err(GuestbookError::DuplicateTag(TAG_TARGET));
295                    }
296                    target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
297                }
298            }
299            let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
300            GuestbookEntry::Kick {
301                actor: opened.author,
302                target,
303                citation: AuthorityCitation::from_tags(&rumor.tags),
304                at_ms,
305            }
306        }
307        kind::SNAPSHOT => {
308            let mut snap: Option<(String, String, String)> = None;
309            for t in rumor.tags.iter() {
310                let s = t.as_slice();
311                if s.len() >= 2 && s[0] == TAG_SNAP {
312                    if snap.is_some() {
313                        return Err(GuestbookError::DuplicateTag(TAG_SNAP));
314                    }
315                    if s.len() < 4 {
316                        return Err(GuestbookError::BadTag(TAG_SNAP));
317                    }
318                    snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
319                }
320            }
321            // The snap tag is load-bearing (the one-id-one-time consistency
322            // rule keys on it), so any malformation rejects the whole event.
323            let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
324            if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
325                return Err(GuestbookError::BadTag(TAG_SNAP));
326            }
327            let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
328            let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
329            let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
330            if i < 1 || i > n {
331                return Err(GuestbookError::BadTag(TAG_SNAP));
332            }
333            let raw: Vec<serde_json::Value> =
334                serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
335            // Malformed member entries drop INDIVIDUALLY: a snapshot is
336            // secondhand seeding and absence just means "no seed" (§5), so one
337            // bad entry shouldn't cost the other 399 theirs — the gap heals by
338            // observation or the victim's own fresh Join.
339            let members = raw
340                .iter()
341                .filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
342                .collect();
343            GuestbookEntry::Snapshot {
344                refounder: opened.author,
345                members,
346                snapshot_id,
347                chunk: (i, n),
348                at_ms,
349            }
350        }
351        k => return Err(GuestbookError::NotGuestbook(k)),
352    };
353
354    Ok(GuestbookEvent { epoch: None, observed_at: None, rumor_id: opened.rumor_id.to_bytes(), entry })
355}
356
357// ── Coalesce fold (CORD-02 §5) ───────────────────────────────────────────────
358
359/// An npub's final coalesced state.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum Verdict {
362    Joined,
363    Left,
364    Kicked,
365    /// Dropped by a Refounding: the epoch's minter published a snapshot that
366    /// does not list them, and nothing newer re-admits them.
367    Refounded,
368}
369
370/// Whether the winning entry was the member's own word or a refounder's
371/// secondhand snapshot seed.
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum Source {
374    Firsthand,
375    Snapshot,
376}
377
378/// One npub's folded guestbook state — the winning entry, flat.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct MemberState {
381    pub verdict: Verdict,
382    /// Millisecond time of the winning entry.
383    pub at_ms: u64,
384    pub source: Source,
385    /// Invite attribution (firsthand Joins only): `(creator hex, label)`.
386    pub invited_by: Option<(String, String)>,
387    /// The winning entry's inner rumor id — the tie-break identity.
388    pub rumor_id: [u8; 32],
389}
390
391/// Does `next` beat `prev`? Later ms wins; at a tie a firsthand entry beats a
392/// snapshot seed (a member's own word over the refounder's attestation), then
393/// the lower rumor id. The id tie-break is author-grindable — an accepted
394/// residual: the coalesce is per-npub, so an author only ever grinds ties
395/// against their own entries (CORD-02 §5).
396fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
397    if next.at_ms != prev.at_ms {
398        return next.at_ms > prev.at_ms;
399    }
400    if next.source != prev.source {
401        return next.source == Source::Firsthand;
402    }
403    next.rumor_id < prev.rumor_id
404}
405
406fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
407    match fold.get(&member) {
408        Some(prev) if !supersedes(prev, &next) => {}
409        _ => {
410            fold.insert(member, next);
411        }
412    }
413}
414
415/// Coalesce parsed guestbook events flat: one final [`MemberState`] per npub
416/// (CORD-02 §5), order-independent over `events`.
417///
418///   - entries dated more than [`MAX_FUTURE_MS`] ahead of `now_ms` drop
419///     outright (the forged-future "latest" squat);
420///   - a Kick is honored only when `can_kick(actor, target)` — the caller
421///     closes that over its folded roster (KICK bit + strict outrank);
422///   - a Snapshot is honored ONLY from `snapshot_authority`, the npub whose
423///     Refounding minted the epoch (`None` — unknown or genesis — honors no
424///     snapshots; there is deliberately NO owner fallback, an owner who didn't
425///     mint the epoch has no snapshot authority over it);
426///   - all chunks of one snapshot id must share one `(created_at, ms)`: the
427///     first-seen chunk pins it, disagreeing chunks drop. Pinning `at_ms` pins
428///     the pair — it's a bijection of `(created_at, ms)` under the strict
429///     `0..=999` ms rule [`stream::open_wrap`] already enforced;
430///   - a snapshot merely SEEDS `Joined` at its timestamp: any firsthand entry
431///     (or authorized Kick) newer than it — or tying it — supersedes.
432pub fn coalesce(
433    events: &[GuestbookEvent],
434    now_ms: u64,
435    snapshot_authority: Option<&PublicKey>,
436    can_kick: &dyn Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
437) -> BTreeMap<PublicKey, MemberState> {
438    let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
439    let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
440
441    for ev in events {
442        if ev.entry.at_ms() > horizon {
443            continue;
444        }
445        match &ev.entry {
446            GuestbookEntry::Join { member, invited_by, at_ms } => apply(
447                &mut fold,
448                *member,
449                MemberState {
450                    verdict: Verdict::Joined,
451                    at_ms: *at_ms,
452                    source: Source::Firsthand,
453                    invited_by: invited_by.clone(),
454                    rumor_id: ev.rumor_id,
455                },
456            ),
457            GuestbookEntry::Leave { member, at_ms } => apply(
458                &mut fold,
459                *member,
460                MemberState {
461                    verdict: Verdict::Left,
462                    at_ms: *at_ms,
463                    source: Source::Firsthand,
464                    invited_by: None,
465                    rumor_id: ev.rumor_id,
466                },
467            ),
468            GuestbookEntry::Kick { actor, target, at_ms, citation } => {
469                if !can_kick(actor, target, citation.as_ref()) {
470                    continue;
471                }
472                apply(
473                    &mut fold,
474                    *target,
475                    MemberState {
476                        verdict: Verdict::Kicked,
477                        at_ms: *at_ms,
478                        source: Source::Firsthand,
479                        invited_by: None,
480                        rumor_id: ev.rumor_id,
481                    },
482                );
483            }
484            GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
485                if snapshot_authority != Some(refounder) {
486                    continue;
487                }
488                // Each authorized chunk seeds its own members at its own at_ms,
489                // with NO cross-chunk consistency gate. CORD-02 §5: "chunks are
490                // independently useful ... there is no torn state to defend
491                // against." A first-seen timestamp pin would make a maliciously
492                // torn snapshot resolve differently by relay delivery order,
493                // breaking the deterministic-when-synced guarantee; the per-npub
494                // fold below (latest-ms wins, firsthand beats snapshot, lower
495                // rumor id ties) is commutative, so seeding every chunk converges.
496                for m in members {
497                    apply(
498                        &mut fold,
499                        *m,
500                        MemberState {
501                            verdict: Verdict::Joined,
502                            at_ms: *at_ms,
503                            source: Source::Snapshot,
504                            invited_by: None,
505                            rumor_id: ev.rumor_id,
506                        },
507                    );
508                }
509            }
510        }
511    }
512
513    supersede_by_refounding(&mut fold, events, horizon, snapshot_authority);
514    fold
515}
516
517/// A Refounding's guestbook replaces the old one. A local store appends across
518/// epochs, so without this a member whose Join predates a Refounding stays
519/// "Joined" forever on any client that never witnessed their Kick — and every
520/// client's count drifts by whatever it happened to miss.
521///
522/// The guest list is the union of every honored snapshot ON THE LIVE EPOCH
523/// (the minter's refound snapshot plus any addendum it publishes later — within
524/// one epoch, snapshots only ever add). Any `Joined` state won by an entry from
525/// an EARLIER epoch, and not in that list, becomes [`Verdict::Refounded`] at the
526/// refound's time. Entries that never got an epoch stamp are judged by time:
527/// older than the refounding means an earlier plane. Forward observation
528/// re-admits as usual — posting after the cut needs the new key.
529///
530/// Nothing happens without an epoch-stamped, honored snapshot: a store that
531/// only knows one plane, or one that has not synced since a rotation, is left
532/// exactly as it was.
533/// The live epoch's refounding, as a guest list plus the moment it took effect.
534/// The base is the union of every honored snapshot ON THE LIVE EPOCH (the refound
535/// snapshot plus any addendum — within one epoch snapshots only add); the cut is
536/// the earliest of them, the refound itself.
537pub struct Refound {
538    pub cut: u64,
539    pub base: BTreeSet<PublicKey>,
540    pub rumor_id: [u8; 32],
541}
542
543pub fn live_refound(
544    events: &[GuestbookEvent],
545    snapshot_authority: Option<&PublicKey>,
546    horizon: u64,
547) -> Option<Refound> {
548    let authority = snapshot_authority?;
549    let live = events.iter().filter_map(|e| e.epoch).max()?;
550    let mut base: BTreeSet<PublicKey> = BTreeSet::new();
551    let mut cut: Option<u64> = None;
552    let mut rumor_id: Option<[u8; 32]> = None;
553    for ev in events {
554        if let GuestbookEntry::Snapshot { refounder, members, at_ms, .. } = &ev.entry {
555            if ev.epoch == Some(live) && refounder == authority && *at_ms <= horizon {
556                base.extend(members.iter().copied());
557                cut = Some(cut.map_or(*at_ms, |c| c.min(*at_ms)));
558                rumor_id = Some(rumor_id.map_or(ev.rumor_id, |r| r.min(ev.rumor_id)));
559            }
560        }
561    }
562    Some(Refound { cut: cut?, base, rumor_id: rumor_id? })
563}
564
565fn supersede_by_refounding(
566    fold: &mut BTreeMap<PublicKey, MemberState>,
567    events: &[GuestbookEvent],
568    horizon: u64,
569    snapshot_authority: Option<&PublicKey>,
570) {
571    let Some(Refound { cut, base, rumor_id }) = live_refound(events, snapshot_authority, horizon) else { return };
572    // A Joined member the refound omitted is gone, whether their Join sits on an
573    // earlier plane (stamp) or simply predates the cut (a lost/legacy stamp, or a
574    // wedged client that posted a Join with a stale timestamp). Base membership is
575    // the authority; forward observation still re-admits (the observed path).
576    let live = events.iter().filter_map(|e| e.epoch).max();
577    let provenance: BTreeMap<[u8; 32], Option<u64>> = events.iter().map(|e| (e.rumor_id, e.epoch)).collect();
578    for (pk, st) in fold.iter_mut() {
579        if st.verdict != Verdict::Joined || base.contains(pk) {
580            continue;
581        }
582        let older_plane = matches!((provenance.get(&st.rumor_id).copied().flatten(), live), (Some(e), Some(l)) if e < l);
583        if st.at_ms < cut || older_plane {
584            *st = MemberState { verdict: Verdict::Refounded, at_ms: cut, source: Source::Snapshot, invited_by: None, rumor_id };
585        }
586    }
587}
588
589// ── Complete Memberlist (CORD-02 §5) ─────────────────────────────────────────
590
591/// The Complete Memberlist: coalesced `Joined` members ∪ observed authors,
592/// minus the Banlist. `observed` maps author → the newest ms they were seen
593/// publishing anywhere in the Community (an author seen publishing is
594/// *observably present*, included even if their Join never arrived).
595/// Observation counts FORWARD only: it re-enters an author whose activity is
596/// strictly newer than their latest departure — a departed member's old
597/// history can never resurrect them.
598pub fn complete_memberlist(
599    coalesced: &BTreeMap<PublicKey, MemberState>,
600    observed: &BTreeMap<PublicKey, u64>,
601    banlist: &BTreeSet<PublicKey>,
602    banned_at: &BTreeMap<PublicKey, u64>,
603    refound: Option<&Refound>,
604) -> BTreeSet<PublicKey> {
605    // A Refounding's snapshot is the whole guest list. The observed-forward path
606    // (below) re-admits anyone seen publishing — including raid spammers whose Kick
607    // was buried by the rotation, which is how a client's count drifts above the
608    // real membership. So an observed author the refound omitted, whose activity is
609    // no newer than the cut, is suppressed exactly like a pre-ban ghost: only
610    // posting AFTER the refound (which needs the new key) re-admits them.
611    // `ms == 0` is the grantee sentinel (a role-holder present-via-Grant, not by
612    // activity time). A Grant outranks a snapshot omission — the CONSENSUS-COMPLETE
613    // backstop must survive a Refounding — so never suppress it here.
614    let stale_pre_refound = |pk: &PublicKey, ms: u64| {
615        ms != 0 && refound.is_some_and(|r| !r.base.contains(pk) && ms <= r.cut)
616    };
617    // A Join or activity predating a member's most recent ban is STALE. A ban is a
618    // departure the Guestbook never records (the removal happens on the Control Plane),
619    // so without this an un-ban resurrects their old Join as a phantom member — listed
620    // in a community they hold no key to. Anything AFTER the ban is a genuine rejoin.
621    // `banned_at` is SECONDS, entry times are ms.
622    //
623    // A member offline across the whole ban→un-ban window never actually left; they are
624    // suppressed only until they next publish, and the observed path re-admits them.
625    let stale_pre_ban = |pk: &PublicKey, ms: u64| banned_at.get(pk).is_some_and(|at| ms <= at.saturating_mul(1000));
626    let mut out = BTreeSet::new();
627    for (pk, st) in coalesced {
628        if st.verdict == Verdict::Joined && !banlist.contains(pk) && !stale_pre_ban(pk, st.at_ms) {
629            out.insert(*pk);
630        }
631    }
632    for (pk, seen_ms) in observed {
633        if banlist.contains(pk) || stale_pre_ban(pk, *seen_ms) || stale_pre_refound(pk, *seen_ms) {
634            continue;
635        }
636        match coalesced.get(pk) {
637            Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
638            _ => {
639                out.insert(*pk);
640            }
641        }
642    }
643    out
644}
645
646#[cfg(test)]
647mod tests {
648    use super::super::super::{CommunityId, Epoch};
649    use super::super::derive::guestbook_group_key;
650    use super::*;
651
652    /// A stable "receiver clock" for the fold tests, far above every event time.
653    const NOW: u64 = 1_722_500_000_000;
654
655    fn cid() -> CommunityId {
656        CommunityId([0x33; 32])
657    }
658
659    fn group() -> GroupKey {
660        guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
661    }
662
663    fn pk() -> PublicKey {
664        Keys::generate().public_key()
665    }
666
667    fn always(_: &PublicKey, _: &PublicKey, _: Option<&AuthorityCitation>) -> bool {
668        true
669    }
670
671    /// Full wire path: seal → wrap → open → parse.
672    fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
673        let g = group();
674        let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
675        parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
676    }
677
678    fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
679        let g = group();
680        let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
681        parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
682    }
683
684    // Direct coalesce-input constructors: `id` fills the rumor id, so tie
685    // ordering is choosable per test.
686    fn at_epoch(mut ev: GuestbookEvent, epoch: u64) -> GuestbookEvent {
687        ev.epoch = Some(epoch);
688        ev
689    }
690
691    fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
692        GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
693    }
694
695    fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
696        GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
697    }
698
699    fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
700        GuestbookEvent { epoch: None, observed_at: None, rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
701    }
702
703    fn snap_ev(
704        refounder: PublicKey,
705        members: Vec<PublicKey>,
706        snap: u8,
707        chunk: (u32, u32),
708        at_ms: u64,
709        id: u8,
710    ) -> GuestbookEvent {
711        GuestbookEvent { epoch: None, observed_at: None,
712            rumor_id: [id; 32],
713            entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
714        }
715    }
716
717    #[test]
718    fn join_and_leave_round_trip_through_the_stream() {
719        let member = Keys::generate();
720        let creator = pk().to_hex();
721
722        let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
723        let ev = through(&join, &member);
724        assert_eq!(
725            ev.entry,
726            GuestbookEntry::Join {
727                member: member.public_key(),
728                invited_by: Some((creator, "Reddit".into())),
729                at_ms: 1_722_400_000_128,
730            }
731        );
732
733        // Attribution is optional on a Join, and a Leave never carries any.
734        let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
735        assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
736        let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
737        assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
738    }
739
740    #[test]
741    fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
742        let admin = Keys::generate();
743        let target = pk();
744        let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
745        let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
746
747        let g = group();
748        let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
749        let opened = stream::open_wrap(&wrap, &g).unwrap();
750        let ev = parse_guestbook_event(&opened).unwrap();
751
752        assert_eq!(
753            ev.entry,
754            GuestbookEntry::Kick {
755                actor: admin.public_key(),
756                target,
757                citation: Some(cite),
758                at_ms: 1_722_410_000_301,
759            }
760        );
761        // The tie-break identity is the INNER rumor id, never the wrap's
762        // (which differs per re-wrap and would fork clients on ties).
763        assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
764        assert_ne!(ev.rumor_id, wrap.id.to_bytes());
765
766        // Uncited kick: the owner acting supreme — citation is simply None.
767        let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
768        assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
769    }
770
771    #[test]
772    fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
773        let refounder = Keys::generate();
774        let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
775        let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
776        assert_eq!(rumors.len(), 2);
777
778        let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
779        let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
780             GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
781            (&parsed[0].entry, &parsed[1].entry)
782        else {
783            panic!("expected two snapshot entries");
784        };
785        assert_eq!((m1.len(), m2.len()), (400, 1));
786        assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
787        assert_eq!(id1, &[0x5a; 32]);
788        assert_eq!(id1, id2);
789        assert_eq!(t1, t2, "all chunks share one timestamp");
790        assert_eq!(*r1, refounder.public_key());
791        let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
792        assert_eq!(all, members, "membership survives the chunking intact");
793
794        // No survivors still publishes one observable (empty) chunk.
795        let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
796        assert_eq!(empty.len(), 1);
797        let ev = through(&empty[0], &refounder);
798        assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
799    }
800
801    #[test]
802    fn plaintext_sealed_guestbook_events_are_rejected() {
803        // An encrypted seal is what keeps a membership record from being a
804        // liftable public artifact — the plaintext form must never be honored.
805        let member = Keys::generate();
806        let g = group();
807        let rumor = build_join_rumor(member.public_key(), None, 1_000);
808        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
809        let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
810        let opened = stream::open_wrap(&wrap, &g).unwrap();
811        assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
812    }
813
814    #[test]
815    fn future_dated_entries_are_dropped_outright() {
816        let m = pk();
817        // A +2h forged date squatting "latest": dropped, so the honest +59min
818        // leave (inside the skew allowance) holds the head.
819        let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
820        let ok = leave_ev(m, NOW + 59 * 60_000, 2);
821        let fold = coalesce(&[squat, ok], NOW, None, &always);
822        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
823
824        // Exactly +1h is still allowed — only strictly-greater drops.
825        let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
826        let fold = coalesce(&[edge], NOW, None, &always);
827        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
828    }
829
830    #[test]
831    fn latest_wins_per_npub_join_leave_rejoin() {
832        let m = pk();
833        let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
834
835        let fold = coalesce(&evs, NOW, None, &always);
836        let st = fold.get(&m).unwrap();
837        assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
838        assert_eq!(st.at_ms, 3_000);
839
840        // Order-independent: a shuffled delivery folds identically.
841        let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
842        assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
843
844        // Without the rejoin, leave-after-join is Left.
845        let fold = coalesce(&evs[..2], NOW, None, &always);
846        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
847    }
848
849    #[test]
850    fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
851        let m = pk();
852        let auth = pk();
853
854        // Firsthand vs snapshot at one instant: the member's own word wins even
855        // though the snapshot holds the lower (would-otherwise-win) rumor id.
856        let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
857        let leave = leave_ev(m, 5_000, 0xff);
858        for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
859            let fold = coalesce(&evs, NOW, Some(&auth), &always);
860            let st = fold.get(&m).unwrap();
861            assert_eq!(st.verdict, Verdict::Left);
862            assert_eq!(st.source, Source::Firsthand);
863        }
864
865        // Two firsthand entries at one instant: the lower rumor id wins.
866        let join = join_ev(m, 6_000, 0x01);
867        let leave = leave_ev(m, 6_000, 0x02);
868        for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
869            let fold = coalesce(&evs, NOW, None, &always);
870            assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
871        }
872        // And symmetrically when the leave holds the lower id.
873        let join = join_ev(m, 7_000, 0x02);
874        let leave = leave_ev(m, 7_000, 0x01);
875        let fold = coalesce(&[join, leave], NOW, None, &always);
876        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
877    }
878
879    #[test]
880    fn kick_needs_the_callers_authority_verdict() {
881        let admin = pk();
882        let m = pk();
883        let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
884
885        let denied = coalesce(&evs, NOW, None, &|_, _, _| false);
886        assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
887
888        let granted = coalesce(&evs, NOW, None, &|actor, target, _| *actor == admin && *target == m);
889        let st = granted.get(&m).unwrap();
890        assert_eq!(st.verdict, Verdict::Kicked);
891        assert_eq!(st.at_ms, 2_000);
892    }
893
894    #[test]
895    fn snapshots_are_honored_only_from_the_epochs_refounder() {
896        let refounder = pk();
897        let impostor = pk();
898        let m = pk();
899
900        // Wrong npub: ignored entirely — no owner fallback, no partial honor.
901        let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
902        assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
903
904        // Unknown authority: NO snapshots honored.
905        let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
906        assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
907
908        // The minting refounder seeds Joined at the snapshot's time.
909        let fold = coalesce(&[real], NOW, Some(&refounder), &always);
910        let st = fold.get(&m).unwrap();
911        assert_eq!(st.verdict, Verdict::Joined);
912        assert_eq!(st.source, Source::Snapshot);
913        assert_eq!(st.at_ms, 5_000);
914    }
915
916    #[test]
917    fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
918        let refounder = pk();
919        let m = pk();
920        let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
921
922        // A newer firsthand Leave supersedes the secondhand seed.
923        let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
924        assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
925
926        // An OLDER firsthand Join does not override the newer seed.
927        let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
928        let st = fold.get(&m).unwrap();
929        assert_eq!(st.source, Source::Snapshot);
930        assert_eq!(st.at_ms, 5_000);
931    }
932
933    #[test]
934    fn a_refounding_replaces_the_guestbook_it_supersedes() {
935        let refounder = pk();
936        let (kept, stale, wedged, unstamped_current, later) = (pk(), pk(), pk(), pk(), pk());
937        let events = [
938            // Pre-refound rows a store carried over (never stamped).
939            join_ev(kept, 1_000, 1),
940            join_ev(stale, 1_000, 2),
941            // A client still on the OLD plane posted this Join AFTER the refound;
942            // only the stamp can tell it apart from a real one.
943            at_epoch(join_ev(wedged, 9_000, 3), 6),
944            // Stored before stamping existed but dated after the refound: a
945            // current-plane row that was never re-read. Time keeps it.
946            join_ev(unstamped_current, 5_500, 4),
947            at_epoch(snap_ev(refounder, vec![kept], 0x01, (1, 1), 5_000, 5), 7),
948            at_epoch(join_ev(later, 6_000, 6), 7),
949        ];
950        let fold = coalesce(&events, NOW, Some(&refounder), &always);
951        assert_eq!(fold.get(&kept).unwrap().verdict, Verdict::Joined, "listed: stays");
952        assert_eq!(fold.get(&later).unwrap().verdict, Verdict::Joined, "joined on the new plane");
953        assert_eq!(fold.get(&unstamped_current).unwrap().verdict, Verdict::Joined, "unstamped but dated after the cut");
954        for (who, why) in [(&stale, "older plane by time"), (&wedged, "older plane by stamp, whatever its clock says")] {
955            let st = fold.get(who).unwrap();
956            assert_eq!(st.verdict, Verdict::Refounded, "{why}");
957            assert_eq!(st.at_ms, 5_000, "departed at the refound's time");
958        }
959
960        // The memberlist agrees, and forward observation still re-admits:
961        // posting after the cut needs the new key, so it is proof of membership.
962        let none = BTreeSet::new();
963        let list = complete_memberlist(&fold, &BTreeMap::new(), &none, &BTreeMap::new(), None);
964        assert!(list.contains(&kept) && list.contains(&later) && !list.contains(&stale));
965        let mut seen = BTreeMap::new();
966        seen.insert(stale, 4_000u64);
967        assert!(!complete_memberlist(&fold, &seen, &none, &BTreeMap::new(), None).contains(&stale), "old activity does not resurrect");
968        seen.insert(stale, 7_000u64);
969        assert!(complete_memberlist(&fold, &seen, &none, &BTreeMap::new(), None).contains(&stale), "activity after the cut re-admits");
970    }
971
972    #[test]
973    fn a_refound_suppresses_an_observed_spammer_the_snapshot_omits() {
974        // The bug behind the raid: a spammer's Kick was buried by the rotation, so
975        // their spam (observed activity) re-admits them via forward observation even
976        // though the refound snapshot never listed them. The refound must gate the
977        // observed path too. No guestbook Join for these — they exist only as authors.
978        let (kept, spammer, rejoiner) = (pk(), pk(), pk());
979        let refound = Refound { cut: 5_000, base: BTreeSet::from([kept]), rumor_id: [9u8; 32] };
980        let none = BTreeSet::new();
981        let mut observed = BTreeMap::new();
982        observed.insert(kept, 4_000u64);      // in base: kept regardless of when they last spoke
983        observed.insert(spammer, 4_000u64);   // omitted + pre-cut: suppressed
984        observed.insert(rejoiner, 7_000u64);  // omitted BUT posted after the refound: has the key
985        let list = complete_memberlist(&BTreeMap::new(), &observed, &none, &BTreeMap::new(), Some(&refound));
986        assert!(list.contains(&kept), "base member stays");
987        assert!(!list.contains(&spammer), "buried-kick spammer is not re-admitted by old spam");
988        assert!(list.contains(&rejoiner), "posting after the cut needs the new key, so it re-admits");
989        // Without the refound the old behavior stands (spammer re-admitted) — the gate
990        // only ever removes, never adds.
991        let ungated = complete_memberlist(&BTreeMap::new(), &observed, &none, &BTreeMap::new(), None);
992        assert!(ungated.contains(&spammer), "no refound, no gate");
993
994        // A grantee (present at the ts-0 sentinel) the snapshot omitted still survives:
995        // a Grant outranks a snapshot omission (the CONSENSUS-COMPLETE backstop).
996        let grantee = pk();
997        let mut with_grantee = observed.clone();
998        with_grantee.insert(grantee, 0);
999        let list = complete_memberlist(&BTreeMap::new(), &with_grantee, &none, &BTreeMap::new(), Some(&refound));
1000        assert!(list.contains(&grantee), "a grantee survives the refound gate");
1001    }
1002
1003    #[test]
1004    fn within_one_epoch_snapshots_only_add() {
1005        // A refound snapshot plus a later rescue ADDENDUM on the same epoch: the
1006        // addendum names only the rescued, and must not read as the whole list.
1007        let refounder = pk();
1008        let (a, b, c) = (pk(), pk(), pk());
1009        let events = [
1010            join_ev(a, 1_000, 1),
1011            join_ev(b, 1_000, 2),
1012            join_ev(c, 1_000, 3),
1013            at_epoch(snap_ev(refounder, vec![a], 0x01, (1, 1), 5_000, 4), 7),
1014            at_epoch(snap_ev(refounder, vec![b], 0x02, (1, 1), 8_000, 5), 7),
1015        ];
1016        let fold = coalesce(&events, NOW, Some(&refounder), &always);
1017        assert_eq!(fold.get(&a).unwrap().verdict, Verdict::Joined);
1018        assert_eq!(fold.get(&b).unwrap().verdict, Verdict::Joined, "the addendum adds, it never evicts");
1019        assert_eq!(fold.get(&c).unwrap().verdict, Verdict::Refounded);
1020        assert_eq!(fold.get(&c).unwrap().at_ms, 5_000, "the cut is the refound, not the addendum");
1021    }
1022
1023    #[test]
1024    fn the_latest_refounding_is_the_one_that_counts() {
1025        let refounder = pk();
1026        let (a, b) = (pk(), pk());
1027        // Epoch 3 listed both; epoch 7 listed only `a`. A store holding both
1028        // snapshots must read like a client that only ever saw epoch 7.
1029        let events = [
1030            join_ev(a, 1_000, 1),
1031            join_ev(b, 1_000, 2),
1032            at_epoch(snap_ev(refounder, vec![a, b], 0x03, (1, 1), 3_000, 3), 3),
1033            at_epoch(snap_ev(refounder, vec![a], 0x07, (1, 1), 7_000, 4), 7),
1034        ];
1035        let fold = coalesce(&events, NOW, Some(&refounder), &always);
1036        assert_eq!(fold.get(&a).unwrap().verdict, Verdict::Joined);
1037        assert_eq!(fold.get(&b).unwrap().verdict, Verdict::Refounded);
1038        assert_eq!(fold.get(&b).unwrap().at_ms, 7_000);
1039    }
1040
1041    #[test]
1042    fn only_the_minters_stamped_snapshot_can_evict() {
1043        let (refounder, impostor, m) = (pk(), pk(), pk());
1044        let forged = [join_ev(m, 1_000, 1), at_epoch(snap_ev(impostor, vec![], 0x01, (1, 1), 5_000, 2), 7)];
1045        assert_eq!(coalesce(&forged, NOW, Some(&refounder), &always).get(&m).unwrap().verdict, Verdict::Joined);
1046        assert_eq!(coalesce(&forged, NOW, None, &always).get(&m).unwrap().verdict, Verdict::Joined);
1047        // A genuine snapshot with no epoch stamp (a wire fold, or a store that has
1048        // not synced since the rotation) replaces nothing either.
1049        let unstamped = [join_ev(m, 1_000, 1), snap_ev(refounder, vec![], 0x01, (1, 1), 5_000, 2)];
1050        assert_eq!(coalesce(&unstamped, NOW, Some(&refounder), &always).get(&m).unwrap().verdict, Verdict::Joined);
1051    }
1052
1053    #[test]
1054    fn a_torn_snapshot_coalesces_order_independently() {
1055        // A maliciously (or buggily) torn snapshot — two chunks of one snap id
1056        // carrying DIFFERENT timestamps — must fold to the SAME member set
1057        // regardless of relay delivery order. (A first-seen timestamp pin made
1058        // the dropped chunk order-dependent, violating determinism; per CORD-02
1059        // §5 there is no torn state to defend against — every authorized chunk
1060        // seeds its members.)
1061        let refounder = pk();
1062        let (a, b, c) = (pk(), pk(), pk());
1063        let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
1064        let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
1065        let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
1066
1067        let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
1068        let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
1069        // Both orders seed all three members, identically.
1070        for m in [&a, &b, &c] {
1071            assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
1072        }
1073        assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
1074    }
1075
1076    #[test]
1077    fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
1078        let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
1079        let coalesced = coalesce(
1080            &[
1081                join_ev(joined, 1_000, 1),
1082                join_ev(left, 1_000, 2),
1083                leave_ev(left, 5_000, 3),
1084                join_ev(banned_joined, 1_000, 4),
1085            ],
1086            NOW,
1087            None,
1088            &always,
1089        );
1090        let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
1091
1092        // Old activity (pre-Leave) never resurrects; silent observed authors
1093        // ARE members; the banlist subtracts unconditionally.
1094        let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
1095        let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None);
1096        assert!(list.contains(&joined));
1097        assert!(list.contains(&silent), "an observed author with no guestbook state is present");
1098        assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
1099        assert!(!list.contains(&banned_joined));
1100        assert!(!list.contains(&banned_observed));
1101
1102        // Activity strictly newer than the departure re-enters them.
1103        let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
1104        let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None);
1105        assert!(list.contains(&left));
1106        // Equal-to-departure is not "newer" — still out.
1107        let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
1108        assert!(!complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new(), None).contains(&left));
1109    }
1110
1111    #[test]
1112    fn an_unban_does_not_resurrect_a_pre_ban_join_or_activity() {
1113        // The reported phantom: ban a member, un-ban them, and their old Join (or old
1114        // messages) put them back in the memberlist of a community they hold no key to.
1115        // CORD-02 §5 counts observation forward of the latest Leave, Kick OR Ban.
1116        let (member, chatty) = (pk(), pk());
1117        let coalesced = coalesce(&[join_ev(member, 1_000, 1), join_ev(chatty, 1_000, 2)], NOW, None, &always);
1118        // Banned at t=5s (marks are SECONDS), then un-banned — so the banlist is empty.
1119        let banned_at: BTreeMap<PublicKey, u64> = [(member, 5), (chatty, 5)].into();
1120        let empty: BTreeSet<PublicKey> = BTreeSet::new();
1121
1122        let observed: BTreeMap<PublicKey, u64> = [(chatty, 4_000)].into();
1123        let list = complete_memberlist(&coalesced, &observed, &empty, &banned_at, None);
1124        assert!(!list.contains(&member), "a pre-ban Join must not survive the un-ban");
1125        assert!(!list.contains(&chatty), "pre-ban activity must not survive the un-ban either");
1126
1127        // A genuine rejoin — anything strictly after the ban — brings them back.
1128        let observed: BTreeMap<PublicKey, u64> = [(chatty, 6_000)].into();
1129        assert!(
1130            complete_memberlist(&coalesced, &observed, &empty, &banned_at, None).contains(&chatty),
1131            "activity newer than the ban is a real rejoin"
1132        );
1133        let rejoined = coalesce(&[join_ev(member, 1_000, 1), join_ev(member, 6_000, 3)], NOW, None, &always);
1134        assert!(
1135            complete_memberlist(&rejoined, &BTreeMap::new(), &empty, &banned_at, None).contains(&member),
1136            "a Join newer than the ban re-admits"
1137        );
1138        // Still banned (not yet un-banned) stays out regardless of the marks.
1139        let banlist: BTreeSet<PublicKey> = [member].into();
1140        assert!(!complete_memberlist(&rejoined, &BTreeMap::new(), &banlist, &banned_at, None).contains(&member));
1141    }
1142
1143    #[test]
1144    fn malformed_verbs_and_foreign_kinds_are_rejected() {
1145        let k = Keys::generate();
1146        // The verb is exact: case variants and paddings are malformed, never
1147        // normalized (a lenient reader would fold state a strict one dropped).
1148        for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
1149            let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
1150            assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
1151        }
1152        let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
1153        assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
1154    }
1155
1156    #[test]
1157    fn kick_target_must_be_a_unique_valid_p_tag() {
1158        let k = Keys::generate();
1159
1160        let dup = stream::build_rumor_ms(
1161            kind::KICK,
1162            k.public_key(),
1163            "",
1164            vec![Tag::public_key(pk()), Tag::public_key(pk())],
1165            1_000,
1166        );
1167        assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
1168
1169        let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
1170        assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
1171
1172        let bad = stream::build_rumor_ms(
1173            kind::KICK,
1174            k.public_key(),
1175            "",
1176            vec![Tag::custom("p", ["not-hex".to_string()])],
1177            1_000,
1178        );
1179        assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
1180    }
1181
1182    #[test]
1183    fn snapshot_snap_tag_and_content_are_strict() {
1184        let k = Keys::generate();
1185        let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
1186        let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
1187            stream::build_rumor_ms(
1188                kind::SNAPSHOT,
1189                k.public_key(),
1190                content,
1191                vec![Tag::custom(
1192                    "snap",
1193                    [id.to_string(), i.to_string(), n.to_string()],
1194                )],
1195                1_000,
1196            )
1197        };
1198
1199        // 0-based, out-of-range, or unparseable indices reject the event.
1200        for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
1201            let r = snap_rumor("[]", &id_hex, i, n);
1202            assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
1203        }
1204        // A bad snapshot id rejects; a missing snap tag rejects; non-array content rejects.
1205        let bad_id = snap_rumor("[]", "zz", "1", "1");
1206        assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
1207        let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
1208        assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
1209        let not_array = snap_rumor("{}", &id_hex, "1", "1");
1210        assert!(matches!(through_err(&not_array, &k), GuestbookError::BadSnapshotContent));
1211    }
1212
1213    #[test]
1214    fn snapshot_bad_member_entries_drop_individually() {
1215        let k = Keys::generate();
1216        let good = pk();
1217        // One valid member among garbage hex and a non-string: the good seed
1218        // survives — secondhand seeding never fails wholesale on one entry.
1219        let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
1220        let rumor = stream::build_rumor_ms(
1221            kind::SNAPSHOT,
1222            k.public_key(),
1223            &content,
1224            vec![Tag::custom(
1225                "snap",
1226                [crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
1227            )],
1228            1_000,
1229        );
1230        let ev = through(&rumor, &k);
1231        assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
1232    }
1233}