Skip to main content

vector_core/community/v2/
service.rs

1//! Concord v2 service — the stateful orchestration binding the pure v2 modules
2//! to storage + transport. Free functions, `std::sync::Arc<crate::db::Session>`-gated at every write
3//! (a `swap_session` can land at any await — see CLAUDE.md), mirroring the v1
4//! service's discipline.
5//!
6//! Signing + NIP-44 flow through the active [`VectorSigner`] (`active_signer()`):
7//! the live client's signer for a NIP-46 bunker / NIP-55 offline account, else the
8//! local vault. Every identity op in v2 is `sign_event` / `nip44_encrypt` /
9//! `nip44_decrypt` — a remote signer's whole surface — so create, send, join,
10//! invite, moderate, rotate, and refound all work keylessly (CORD-06 D1/D5 made the
11//! rekey locator public + its blobs pairwise NIP-44, so unlike v1 there is no
12//! raw-ECDH exception).
13
14use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
15
16use super::super::transport::{Query, Transport};
17use super::super::{version, ChannelId, Epoch};
18use super::chat::{self, ChatEvent};
19use super::community::{ChannelV2, CommunityV2};
20use super::control;
21use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, GroupKey};
22use super::invite::{self, CommunityInvite};
23use super::rekey::{self, Continuity, RekeyScope};
24use super::{guestbook, stream, vsk};
25use crate::community::edition::ParsedEdition;
26
27/// The active signer for v2 authority actions: the live client's signer — which
28/// covers a NIP-46 bunker / NIP-55 offline signer — falling back to the local
29/// vault keys when there is no client or no signer attached (local accounts,
30/// headless/CLI paths, and tests). Every v2 seal, rekey blob, and control edition
31/// signs / NIP-44-wraps through this, so a keyless account can create AND
32/// administer a community. v2's rekey locator is public + its blobs are pairwise
33/// NIP-44 (CORD-06 D1/D5), so unlike v1 there is no raw-ECDH exception.
34/// The active identity's public key for addressing/tags — authoritative (set at
35/// login), no signer round-trip. Used everywhere v2 needs "who am I" so a keyless
36/// account (empty vault) still resolves its own identity.
37fn me_pk() -> Result<PublicKey, String> {
38    crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
39}
40
41fn now_ms() -> u64 {
42    web_time::SystemTime::now()
43        .duration_since(web_time::UNIX_EPOCH)
44        .map(|d| d.as_millis() as u64)
45        .unwrap_or(0)
46}
47
48/// `now_ms`, but never twice the same value in this process.
49///
50/// Ordering is by the `ms` tag, and two sends can finish inside one millisecond
51/// — so a sender's own back-to-back messages carried IDENTICAL stamps and fell
52/// through to the reader's tiebreak, which is content-derived and therefore
53/// indifferent to which was typed first. Bumping past the last stamp keeps a
54/// single sender's sequence self-consistent; readers still tiebreak across
55/// senders. Drifts at most a few ms ahead of the clock under a burst, and only
56/// until the clock catches up.
57fn next_send_ms() -> u64 {
58    use std::sync::atomic::{AtomicU64, Ordering};
59    static LAST: AtomicU64 = AtomicU64::new(0);
60    let now = now_ms();
61    LAST.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| Some(now.max(last + 1)))
62        .map(|last| now.max(last + 1))
63        .unwrap_or(now)
64}
65
66/// Create a fresh v2 community owned by the local identity: mint the genesis
67/// (self-certifying id + the two owner editions), persist, publish the genesis
68/// control editions, and announce the owner's Guestbook Join. Returns the saved
69/// community.
70pub async fn create_community<T: Transport + ?Sized>(
71    transport: &T,
72    name: &str,
73    relays: Vec<String>,
74    description: Option<String>,
75) -> Result<CommunityV2, String> {
76    crate::db::scoped(async move {
77        let signer = crate::signer::active_signer()?;
78        let owner_pk = me_pk()?;
79        let at_ms = now_ms();
80
81        let meta = control::CommunityMetadata {
82            name: name.to_string(),
83            description: description.clone(),
84            relays: relays.clone(),
85            ..Default::default()
86        };
87        let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
88        let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
89
90        // Save-before-publish (like v1 create): no peers exist yet so there's no
91        // shared view to diverge from, and the fresh-random keys are irrecoverable
92        // if a publish hiccup rolled them back. Re-check the session after the genesis
93        // signing await (a bunker signs over the network) before the DB write.
94        // Seed the genesis edition heads (v1) as the owner's refuse-downgrade floor, so a
95        // later edit can't be rolled back by a relay serving only the genesis prefix. The
96        // live control sub is replay-free (limit 0), so the owner won't re-fold its own
97        // genesis to seed the floor otherwise. Floors land BEFORE the community row
98        // (floors-then-state ordering).
99        let control = control::ControlPlane::of(&community);
100        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
101        for wrap in &genesis.wraps {
102            if let Ok((ed, _)) = control.open(wrap) {
103                let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
104                crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
105            }
106        }
107        crate::db::community::save_community_v2(&community)?;
108        // Archive the genesis root at epoch 0, so a later Refounding leaves this epoch's
109        // Public-channel history readable (CORD-03 §3 multi-epoch read).
110        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
111
112        // Publish the two genesis control editions at the epoch-0 control plane.
113        // Durable, not single-shot: over a slow transport (Tor) one attempt is a coin
114        // flip, and a lost genesis leaves a community that exists only locally. Durable
115        // races every relay, returns on the first ACK, then heals stragglers in the bg.
116        for wrap in &genesis.wraps {
117            transport.publish_durable(wrap, &community.relays).await?;
118        }
119
120        // Announce the owner's Guestbook Join so they appear in the memberlist. Relays are
121        // proven-alive by the genesis ACK above, so durable here just guarantees the owner's
122        // own join lands (member count) without a real block risk.
123        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
124        let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
125        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
126            let _ = transport.publish_durable(&join_wrap, &community.relays).await;
127        }
128
129        // Sync the new membership across devices (CORD-02 §8), durably — see the join path.
130        match republish_community_list(transport, Some(community.id())).await {
131            Ok(true) => {}
132            Ok(false) => republish_community_list_durable(Some(*community.id())),
133            Err(e) => {
134                crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
135                republish_community_list_durable(Some(*community.id()));
136            }
137        }
138        Ok(community)
139    })
140    .await
141}
142
143/// Mint a v2 migration TWIN whose primary channel REUSES the v1 primary channel id (§migration)
144/// so chat history stitches through the flip. Same owner identity, fresh salt/root. Additional
145/// v1 channels are added by the wizard via `create_*_channel_with_id`. Mirrors
146/// [`create_community`]'s persist-before-publish + floor seeding.
147pub async fn create_migration_twin<T: Transport + ?Sized>(
148    transport: &T,
149    name: &str,
150    relays: Vec<String>,
151    description: Option<String>,
152    primary: (ChannelId, String),
153) -> Result<CommunityV2, String> {
154    crate::db::scoped(async move {
155        let signer = crate::signer::active_signer()?;
156        let owner_pk = me_pk()?;
157        let at_ms = now_ms();
158
159        let meta = control::CommunityMetadata {
160            name: name.to_string(),
161            description: description.clone(),
162            relays: relays.clone(),
163            ..Default::default()
164        };
165        let primary_name = primary.1.clone();
166        let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
167            .await
168            .map_err(|e| e.to_string())?;
169        let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
170        // from_genesis hard-names the primary "general"; carry the v1 name (the wire edition
171        // already carries it, so this only keeps the owner's immediate local view correct).
172        if let Some(ch) = community.channels.first_mut() {
173            ch.name = primary_name;
174        }
175        let control = control::ControlPlane::of(&community);
176        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
177        for wrap in &genesis.wraps {
178            if let Ok((ed, _)) = control.open(wrap) {
179                let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
180                crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
181            }
182        }
183        crate::db::community::save_community_v2(&community)?;
184        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
185        for wrap in &genesis.wraps {
186            transport.publish_durable(wrap, &community.relays).await?;
187        }
188        // Owner Guestbook Join so they appear in the twin's memberlist.
189        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
190        let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
191        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
192            let _ = transport.publish_durable(&join_wrap, &community.relays).await;
193        }
194        Ok(community)
195    })
196    .await
197}
198
199/// Clone a v1 banlist onto the v2 twin (§migration Phase 1.3): the join-time ban gate needs
200/// the v2 banlist to name every v1-banned npub, else a banned-but-never-cut member who can
201/// open `m` would walk in. Owner-signed on the twin's control plane.
202pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
203    transport: &T,
204    twin: &CommunityV2,
205    banned: &[String],
206) -> Result<(), String> {
207    if banned.is_empty() {
208        return Ok(());
209    }
210    set_banlist(transport, twin, banned).await
211}
212
213/// Clone v1 governance onto the twin (§migration Phase 1.3): every v1 member who was a FULL
214/// admin (effective permissions ⊇ ADMIN_ALL) is re-granted @admin on the twin (mapping v1's
215/// Admin onto v2's deterministic admin role id, CORD-04 §2). The owner is supreme by
216/// identity (never a grant) and banned members are skipped (a banned author's editions fold
217/// out anyway, and re-granting would spring them back to admin on a future unban).
218///
219/// NON-ESCALATION: only a full admin maps to v2 @admin (which holds ADMIN_ALL). A
220/// partial-management v1 role holder (e.g. CREATE_INVITE only — never minted by the v1 UI,
221/// but reachable via the SDK) degrades to a plain member rather than being ESCALATED to full
222/// admin. Bespoke non-admin custom roles are not carried — a documented, non-security gap.
223pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
224    transport: &T,
225    twin: &CommunityV2,
226    v1_roles: &crate::community::roles::CommunityRoles,
227    banned: &[String],
228) -> Result<(), String> {
229    use crate::community::roles::Permissions;
230    let owner = twin.owner()?;
231    for grant in &v1_roles.grants {
232        // Founding mask: v1 admin roles predate PIN_MESSAGES, so requiring the
233        // widened ADMIN_ALL would silently demote every migrating v1 admin.
234        if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_FOUNDING_MASK) {
235            continue; // not a full admin → plain member on v2 (never escalated)
236        }
237        if banned.contains(&grant.member) {
238            continue; // banned → no authority on v2, don't re-arm a future unban
239        }
240        let Ok(member) = PublicKey::parse(&grant.member) else { continue };
241        if member == owner {
242            continue; // supreme by identity — never needs a grant
243        }
244        grant_admin(transport, twin, &member).await?;
245    }
246    Ok(())
247}
248
249/// The twin's JoinMaterial — the membership subset sealed into the migration `m`.
250/// The `m` goes to every migrating MEMBER, so the owner's staff write secret is
251/// stripped: members get the address (read), never the key (CORD-02 §2).
252pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
253    let mut jm = join_material(twin);
254    jm.control_root = None;
255    jm
256}
257
258/// Send a text message to a channel. Derives the channel's Chat-Plane group key
259/// (community_root for a Public channel, the channel key for a Private one),
260/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
261pub async fn send_message<T: Transport + ?Sized>(
262    transport: &T,
263    community: &CommunityV2,
264    channel_id: &ChannelId,
265    content: &str,
266) -> Result<String, String> {
267    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
268}
269
270/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
271/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
272/// attachments). Returns the message's rumor id (hex).
273pub async fn send_chat_message<T: Transport + ?Sized>(
274    transport: &T,
275    community: &CommunityV2,
276    channel_id: &ChannelId,
277    content: &str,
278    reply_to: Option<(&str, &str)>,
279    emoji: &[(&str, &str)],
280    extra_tags: Vec<nostr_sdk::prelude::Tag>,
281) -> Result<String, String> {
282    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, next_send_ms()).await
283}
284
285/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
286/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
287/// its optimistic pending row — the in-process echo and the finalize then key
288/// the SAME id (the exact v1 pending → sent contract).
289#[allow(clippy::too_many_arguments)]
290pub async fn send_chat_message_at<T: Transport + ?Sized>(
291    transport: &T,
292    community: &CommunityV2,
293    channel_id: &ChannelId,
294    content: &str,
295    reply_to: Option<(&str, &str)>,
296    emoji: &[(&str, &str)],
297    extra_tags: Vec<nostr_sdk::prelude::Tag>,
298    at_ms: u64,
299) -> Result<String, String> {
300    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
301    let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
302    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
303}
304
305/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
306/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
307/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
308/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
309#[allow(clippy::too_many_arguments)]
310pub async fn send_reaction<T: Transport + ?Sized>(
311    transport: &T,
312    community: &CommunityV2,
313    channel_id: &ChannelId,
314    target_id_hex: &str,
315    target_author_hex: &str,
316    target_kind: u16,
317    emoji_content: &str,
318    emoji: Option<(&str, &str)>,
319) -> Result<String, String> {
320    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
321    let at_ms = next_send_ms();
322    let rumor =
323        chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
324    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
325}
326
327/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
328/// with the replacement text. Author-enforced on the read side — only the
329/// original author's edit folds.
330pub async fn send_edit<T: Transport + ?Sized>(
331    transport: &T,
332    community: &CommunityV2,
333    channel_id: &ChannelId,
334    target_id_hex: &str,
335    new_content: &str,
336    emoji: &[(&str, &str)],
337) -> Result<String, String> {
338    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
339    let at_ms = next_send_ms();
340    let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, emoji, at_ms);
341    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
342}
343
344/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
345/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
346/// retained per-message stream key (see `publish_chat`).
347pub async fn send_delete<T: Transport + ?Sized>(
348    transport: &T,
349    community: &CommunityV2,
350    channel_id: &ChannelId,
351    target_id_hex: &str,
352    target_kind: u16,
353) -> Result<String, String> {
354    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
355    let at_ms = next_send_ms();
356    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
357    let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
358    // §7: deleting one's own pinned message obliges the immediate omitting
359    // edition. Hooked HERE and not only at ingest — the local delete drops the
360    // row before the relay echo returns, so the echo can't re-authorize and
361    // the ingest hook never sees an own delete.
362    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
363    spawn_pin_duty(&ch_hex, target_id_hex, None);
364    Ok(id)
365}
366
367/// Moderation-hide: remove SOMEONE ELSE's message under `MANAGE_MESSAGES`
368/// (CORD-04 §3/§5). Same kind-5 the author's own delete uses — CORD defines no
369/// separate hide, the authority is what differs, and every reader re-derives it
370/// from the seal's real npub against the folded Roster.
371///
372/// Gated locally against the same predicate peers enforce, so the button can't
373/// promise what the plane will refuse; a non-owner cites the Grant it acts under.
374/// `target_author` comes from the caller's resident copy — you can only moderate
375/// a message you can see.
376pub async fn moderation_delete<T: Transport + ?Sized>(
377    transport: &T,
378    community: &CommunityV2,
379    channel_id: &ChannelId,
380    target_id_hex: &str,
381    target_kind: u16,
382    target_author: &PublicKey,
383) -> Result<String, String> {
384    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
385    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
386    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
387        return Err("this community is dissolved — it accepts no new moderation actions".to_string());
388    }
389    let owner_hex = community.owner()?.to_hex();
390    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
391    if !crate::community::moderation::can_hide(
392        Some(&owner_hex),
393        &roster,
394        &author_pk.to_hex(),
395        &target_author.to_hex(),
396    ) {
397        return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
398    }
399    let at_ms = next_send_ms();
400    let citation = required_authority_citation(community, &author_pk)?;
401    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
402    let id = publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
403    // §7: a moderation-hide of a pinned message obliges the omission too, and
404    // the moderator here provably holds the bit's neighbourhood (MANAGE_MESSAGES
405    // curators usually hold PIN_MESSAGES); the duty itself re-checks.
406    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
407    spawn_pin_duty(&ch_hex, target_id_hex, None);
408    Ok(id)
409}
410
411/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
412/// `publish_webxdc_signal`: the same shared content shape, sealed on the
413/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
414/// Signed by the member's real identity — a member can't forge another
415/// player's presence. Failure is non-fatal to callers (the next re-advertise
416/// covers a missed ad).
417pub async fn send_webxdc_signal<T: Transport + ?Sized>(
418    transport: &T,
419    community: &CommunityV2,
420    channel_id: &ChannelId,
421    topic_id: &str,
422    node_addr: Option<&str>,
423) -> Result<(), String> {
424    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
425    let at_ms = now_ms();
426    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
427    let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
428    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
429}
430
431/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
432pub async fn send_typing<T: Transport + ?Sized>(
433    transport: &T,
434    community: &CommunityV2,
435    channel_id: &ChannelId,
436) -> Result<(), String> {
437    let (author_pk, group, epoch) = chat_send_context(community, channel_id)?;
438    let at_ms = now_ms();
439    let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
440    publish_chat(transport, community, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
441}
442
443/// Everything a chat-plane send needs: local keys, the channel's group key +
444/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
445/// community (every honest member sealed it read-only) and a keyless Private
446/// channel — deriving from the root would post to the public plane; its key
447/// arrives over the rekey plane.
448fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch), String> {
449    let author_pk = me_pk()?;
450    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
451    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
452        return Err("this community has been dissolved".to_string());
453    }
454    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
455    // echo would silently no-op, so fail loudly instead of a message that seems
456    // to send but shows up nowhere.
457    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
458        return Err("you are banned from this community".to_string());
459    }
460    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
461    if ch.private && ch.key.is_none() {
462        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
463    }
464    let (secret, epoch) = community.channel_secret(ch);
465    Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch))
466}
467
468/// Seal one chat rumor, publish, and echo the send into the
469/// shared store. Returns the rumor id (hex).
470#[allow(clippy::too_many_arguments)]
471/// §7: an OWN edit must refresh its pinned target's proof bundle from the SEND
472/// side — the relay echo of an own send deduplicates against the local echo, so
473/// the ingest hook never sees it (send_delete's omission duty, same reason).
474/// Without this the author's pin lags behind their message while everyone
475/// else's follows. Returns the duty's arguments when the echo applied an edit.
476fn own_echo_pin_duty(
477    outcome: &super::inbound::ChatPersist,
478    event: &ChatEvent,
479) -> Option<(String, super::stream::OpenedStream)> {
480    match (outcome, event) {
481        (super::inbound::ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
482            Some((crate::simd::hex::bytes_to_hex_32(target), opened.clone()))
483        }
484        _ => None,
485    }
486}
487
488#[allow(clippy::too_many_arguments)]
489async fn publish_chat<T: Transport + ?Sized>(
490    transport: &T,
491    community: &CommunityV2,
492    group: &GroupKey,
493    author_pk: PublicKey,
494    channel_id: &ChannelId,
495    epoch: Epoch,
496    rumor: nostr_sdk::prelude::UnsignedEvent,
497    at_ms: u64,
498    ephemeral: bool,
499) -> Result<String, String> {
500    crate::db::scoped(async move {
501        let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
502        let signer = crate::signer::active_signer()?;
503        let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
504            .map_err(|e| e.to_string())?;
505        transport.publish(&wrap, &community.relays).await?;
506        // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
507        // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
508        // everywhere — the discarded p-tag pair only works on recipient-delete relays).
509        // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
510        // straddled network I/O.
511        if !ephemeral {
512            crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
513        }
514        // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
515        // send-then-read works with no listen loop, and the relay's re-delivery dedups
516        // against this row instead of re-firing callbacks. Best-effort — the publish
517        // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
518        if !ephemeral {
519            if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
520                let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
521                let outcome = {
522                    let mut st = crate::state::STATE.lock().await;
523                    super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
524                };
525                if let Some(outcome) = outcome {
526                    super::inbound::persist_chat(&channel_hex, &outcome).await;
527                    if let Some((target_hex, opened)) = own_echo_pin_duty(&outcome, &event) {
528                        spawn_pin_duty(&channel_hex, &target_hex, Some(opened));
529                    }
530                }
531            }
532        }
533        Ok(rumor_id)
534    })
535    .await
536}
537
538/// A chat event opened from a channel fetch, tagged with the epoch its key
539/// decrypted under.
540pub struct FetchedEvent {
541    pub event: ChatEvent,
542    pub epoch: Epoch,
543}
544
545/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
546/// pre-retention and other-device sends stay fully deletable, because the wrap's
547/// signing key is the derivable group stream key — only this rumor→wrap mapping
548/// was ever missing locally. No-op for foreign authors, kinds the UI can't
549/// delete, and already-retained rows. Best-effort: a store failure never breaks
550/// the fetch.
551fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
552    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
553        return;
554    }
555    let opened = event.opened();
556    if crate::state::my_public_key() != Some(opened.author) {
557        return;
558    }
559    let rumor_hex = opened.rumor_id.to_hex();
560    // Only fill a confirmed gap — never clobber a send-time row, never write
561    // when the store can't be read.
562    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
563        return;
564    }
565    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
566        // The UI caches full-vs-limited delete verdicts per message; tell it this
567        // one just flipped so it re-resolves without an app restart.
568        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
569    }
570}
571
572/// How many of the newest held epochs a newest-first catch-up reads. One: a
573/// rotation moves the conversation, so the live epoch is where it is. Rotated-past
574/// epochs are reached by back-paging, when a reader actually scrolls for them.
575const LIVE_COORD_EPOCHS: usize = 1;
576
577/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
578/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
579/// per epoch; deeper history pages backwards via the walk.
580pub async fn fetch_channel<T: Transport + ?Sized>(
581    transport: &T,
582    community: &CommunityV2,
583    channel_id: &ChannelId,
584    limit: usize,
585) -> Result<Vec<FetchedEvent>, String> {
586    fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
587}
588
589/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
590/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
591/// address one `page`-sized query at a time until `max_pages`, a drained relay,
592/// or `keep_paging` returns false for a page (the caller's "I already hold
593/// these" early stop — consulted only on pages that opened something, so junk
594/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
595/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
596/// full page of only-already-seen wraps is a same-second WALL (relay filters
597/// are second-granular) and steps past it accepting that unseen same-second
598/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
599/// limitation (the `ms` tag can't be filtered server-side).
600///
601/// Returns everything opened, deduped by rumor id, oldest→newest.
602#[allow(clippy::too_many_arguments)]
603pub async fn fetch_channel_history<T: Transport + ?Sized>(
604    transport: &T,
605    community: &CommunityV2,
606    channel_id: &ChannelId,
607    page: usize,
608    max_pages: usize,
609    since: Option<u64>,
610    // Unix-seconds upper bound for the FIRST page (inclusive) — the back-paging
611    // cursor. `None` starts at the newest.
612    start_until: Option<u64>,
613    evidence: crate::community::transport::Evidence,
614    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
615) -> Result<Vec<FetchedEvent>, String> {
616    crate::db::scoped(async move {
617        // Guards the opportunistic scrub-key heals below — the fetch loop straddles
618        // network I/O, and an account swap must not write into the new account's DB.
619        let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
620        // A Public channel reads across EVERY held base-root epoch, and a Private one
621        // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
622        // continuous either way. A keyless Private channel is unreadable — never derived
623        // from the root (that would address the public plane).
624        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
625        let coords: Vec<([u8; 32], Epoch)> = if ch.private {
626            let Some(current) = ch.key else {
627                return Ok(Vec::new());
628            };
629            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
630            let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
631            if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
632                held.push((ch.epoch, current));
633            }
634            // Only real grants are archived, but keep the invariant local: a private
635            // plane is never read with the root value.
636            held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
637        } else {
638            let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
639            if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
640                roots.push((community.root_epoch, community.community_root));
641            }
642            roots.into_iter().map(|(ep, root)| (root, ep)).collect()
643        };
644        if coords.is_empty() {
645            return Ok(Vec::new());
646        }
647        // Every epoch is its own address on this plane, so the full set costs one
648        // fetch per held epoch per page and grows with the community's whole
649        // rotation history. A newest-first catch-up wants the live epoch, which is
650        // the only one anyone should be speaking on; reaching back through a
651        // rotation is what back-paging (`start_until`) is for — the same division
652        // the boot volley already draws.
653        let mut coords = coords;
654        if start_until.is_none() && coords.len() > LIVE_COORD_EPOCHS {
655            coords.sort_by_key(|(_, e)| std::cmp::Reverse(e.0));
656            coords.truncate(LIVE_COORD_EPOCHS);
657        }
658
659        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
660        let mut seen_rumors = std::collections::HashSet::new();
661        let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
662        let mut until: Option<u64> = start_until;
663        let mut oldest: Option<u64> = None;
664        for _ in 0..max_pages {
665            // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
666            // relays (Ditto) require the connection authed as the author queried and
667            // reject a multi-author REQ ("all authors must be authenticated"), so a
668            // single merged fetch returns nothing there — the latest messages under a
669            // freshly-adopted epoch never load. Per-plane authed fetches + union.
670            let mut wraps: Vec<Event> = Vec::new();
671            let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
672            for (secret, epoch) in &coords {
673                let plane = channel_group_key(secret, channel_id, *epoch);
674                let q = Query {
675                    kinds: vec![stream::KIND_WRAP],
676                    authors: vec![plane.pk_hex()],
677                    since,
678                    until,
679                    limit: Some(page),
680                    evidence,
681                    ..Default::default()
682                };
683                if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
684                    for e in evs.events {
685                        if wrap_ids.insert(e.id) {
686                            wraps.push(e);
687                        }
688                    }
689                }
690            }
691            if wraps.is_empty() {
692                break;
693            }
694            let mut fresh = 0usize;
695            let mut page_events: Vec<FetchedEvent> = Vec::new();
696            for wrap in &wraps {
697                if !seen_wraps.insert(wrap.id) {
698                    continue;
699                }
700                fresh += 1;
701                let at = wrap.created_at.as_secs();
702                if oldest.is_none_or(|o| at < o) {
703                    oldest = Some(at);
704                }
705                // Select the epoch whose group key authored this wrap (no trial decrypt).
706                for (secret, epoch) in &coords {
707                    let group = channel_group_key(secret, channel_id, *epoch);
708                    if wrap.pubkey != group.pk() {
709                        continue;
710                    }
711                    if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
712                        let id = event.opened().rumor_id;
713                        if seen_rumors.insert(id) {
714                            heal_own_wrap_key(&event, &group, &community.relays);
715                            page_events.push(FetchedEvent { event, epoch: *epoch });
716                        }
717                    }
718                    break;
719                }
720            }
721            if fresh == 0 {
722                if wraps.len() < page {
723                    break; // drained — the relay has nothing older.
724                }
725                // A full page of already-seen wraps: a same-second WALL. Step past it;
726                // same-second siblings beyond the relay's cap are unreachable by a
727                // second-granular filter.
728                let Some(o) = oldest else { break };
729                if o == 0 {
730                    break;
731                }
732                crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
733                until = Some(o - 1);
734                continue;
735            }
736            let stop = !page_events.is_empty() && !keep_paging(&page_events);
737            out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
738            if stop {
739                break; // the caller holds everything from here back.
740            }
741            until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
742        }
743        // Ties break on the content-derived rumor id, so every client orders a
744        // same-millisecond pair identically. A bare `ms` sort left ties to whatever
745        // order the relay happened to serve across pages, so two readers could show
746        // the same two messages in opposite orders.
747        out.sort_by(|a, b| {
748            a.0.cmp(&b.0)
749                .then_with(|| a.1.event.opened().rumor_id.as_bytes().cmp(b.1.event.opened().rumor_id.as_bytes()))
750        });
751        Ok(out.into_iter().map(|(_, e)| e).collect())
752    })
753    .await
754}
755
756// ── Invites (CORD-05) ────────────────────────────────────────────────────────
757
758/// Who an invite bundle is FOR — which decides the Private-Channel keys it may
759/// carry (CORD-05 §1 vs §2).
760///
761/// A **Link** has no recipient: "anyone the link reaches can join", so its
762/// audience holds no Role by construction and is entitled to no Private Channel
763/// at all. A **Member** is a specific npub whose entitlement is computable.
764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum BundleAudience {
766    /// A public link (33301 bundle event): public channels only.
767    Link,
768    /// A direct invite (3313) to this npub: may carry Private-Channel keys.
769    Member(PublicKey),
770}
771
772/// Build the §1 invite bundle for this community, scoped to `audience`. A
773/// Public channel carries the `community_root` as its "key" (the joiner derives
774/// the real secret from the root); a Private one its own key — and only for a
775/// Member the folded roster shows entitled. The bundle self-certifies the owner,
776/// so the inviter's identity is irrelevant to trust.
777pub fn bundle_of(
778    community: &CommunityV2,
779    audience: BundleAudience,
780    creator: Option<PublicKey>,
781    expires_at_ms: Option<u64>,
782    label: Option<String>,
783) -> CommunityInvite {
784    bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
785}
786
787/// [`bundle_of`] settling entitlement against a Grant this client JUST published
788/// (`with`/`without` role ids), since the fold lags its own publish. This is the
789/// grant-vend path (CORD-03 "delivered on grant").
790pub fn bundle_of_with_overlay(
791    community: &CommunityV2,
792    audience: BundleAudience,
793    creator: Option<PublicKey>,
794    expires_at_ms: Option<u64>,
795    label: Option<String>,
796    with: &[String],
797    without: &[String],
798) -> CommunityInvite {
799    let hex = crate::simd::hex::bytes_to_hex_32;
800    let cid_hex = hex(&community.identity.community_id.0);
801    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
802    let owner_hex = community.owner().ok().map(|o| o.to_hex());
803    let recipient_hex = match audience {
804        BundleAudience::Link => None,
805        BundleAudience::Member(pk) => Some(pk.to_hex()),
806    };
807    let channels = community
808        .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
809        .into_iter()
810        .map(|c| invite::ChannelGrant {
811            id: hex(&c.id.0),
812            key: hex(&c.key.unwrap_or(community.community_root)),
813            epoch: c.epoch.0,
814            name: c.name.clone(),
815        })
816        .collect();
817    CommunityInvite {
818        community_id: hex(&community.identity.community_id.0),
819        owner: hex(&community.identity.owner_xonly),
820        owner_salt: hex(&community.identity.owner_salt),
821        community_root: hex(&community.community_root),
822        root_epoch: community.root_epoch.0,
823        // Read access to the Control Plane, never write (CORD-02 §7); absent
824        // on a legacy pre-split epoch.
825        control_pk: community.control_pk.map(|p| p.to_hex()),
826        channels,
827        relays: community.relays.clone(),
828        name: community.name.clone(),
829        // Mint-time snapshot so a parked invite renders the real logo before any
830        // fold; the Control Plane stays the authority after joining.
831        icon: community.icon.clone(),
832        expires_at: expires_at_ms,
833        creator_npub: creator.map(|p| p.to_hex()),
834        label,
835        extra: Default::default(),
836    }
837}
838
839/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
840/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
841/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
842/// bundle hands over the keys; the recipient consents by accepting (nothing joins
843/// on receipt). Returns the wrap.
844pub async fn send_direct_invite<T: Transport + ?Sized>(
845    transport: &T,
846    community: &CommunityV2,
847    recipient: &PublicKey,
848    expires_at_ms: Option<u64>,
849    label: Option<String>,
850) -> Result<Event, String> {
851    crate::db::scoped(async move {
852        // A stale bundle is worse than a stale edit: it hands the joiner keys to a
853        // buried epoch, and their client later self-evicts on the rekey exclusion.
854        assert_current_root(community)?;
855        let signer = crate::signer::active_signer()?;
856        let inviter_pk = me_pk()?;
857        let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
858        let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
859        transport.publish(&wrap, &community.relays).await?;
860        Ok(wrap)
861    })
862    .await
863}
864
865/// A minted public link: the shareable URL plus the addressable bundle event to
866/// publish and the link keypair to retain (in the Invite List) for later refresh
867/// or revocation.
868pub struct MintedLink {
869    pub url: String,
870    pub bundle_event: Event,
871    pub link_signer: Keys,
872    pub token: [u8; super::derive::TOKEN_LEN],
873    /// Unix ms, mirrored from the bundle. The Invite List is the creator's only
874    /// record of it, and the Registry prunes on it — the coordinate a member
875    /// folds carries no expiry, so a lapsed link the creator never pruned reads
876    /// as a live door forever (CORD-05 §4/§5).
877    pub expires_at_ms: Option<u64>,
878    pub label: Option<String>,
879}
880
881/// Mint a public invite link for this community: a fresh token + link keypair, the
882/// bundle encrypted under the token key and published at `(33301, link_signer,
883/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
884/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
885/// relays and never reaches a server.
886pub async fn mint_public_link<T: Transport + ?Sized>(
887    transport: &T,
888    community: &CommunityV2,
889    base: &str,
890    expires_at_ms: Option<u64>,
891    label: Option<String>,
892) -> Result<MintedLink, String> {
893    crate::db::scoped(async move {
894        // Readers ignore a registry whose author lacks CREATE_INVITE (it can't even
895        // flip the community Public) — refuse before minting a link nobody honors.
896        ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
897        let mut token = [0u8; super::derive::TOKEN_LEN];
898        token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
899        let link_signer = Keys::generate();
900        let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
901        let bundle_key = super::derive::invite_bundle_key(&token);
902        let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
903        let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
904
905        transport.publish_durable(&bundle_event, &community.relays).await?;
906        let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
907        // Sync the link across the creator's devices (13303) + publish the Registry
908        // (vsk-8) so members see the community is Public. Best-effort — the link works
909        // without the sync.
910        let _ = record_minted_link(transport, community, &minted).await;
911        // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
912        // the 13303 list remains the cross-device record. Re-check the session: the
913        // publishes above straddled awaits, and this write must not land account A's
914        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
915        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
916        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
917        Ok(minted)
918    })
919    .await
920}
921
922// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
923
924/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
925/// decrypt/parse failure is "no news", never a clobber of the local mirror).
926/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
927/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
928/// fresh one wipes every link minted on other devices. Full evidence for the
929/// same reason — this read feeds replaceable-event writes.
930async fn fetch_invite_list<T: Transport + ?Sized>(
931    transport: &T,
932    relays: &[String],
933) -> Result<Option<invite::InviteList>, String> {
934    let signer = crate::signer::active_signer()?;
935    let my_pk = me_pk()?;
936    let query = Query {
937        kinds: vec![super::kind::INVITE_LIST],
938        authors: vec![my_pk.to_hex()],
939        limit: Some(4),
940        evidence: crate::community::transport::Evidence::Full,
941        ..Default::default()
942    };
943    let events = transport.fetch(&query, relays).await?;
944    let mut best: Option<(u64, invite::InviteList)> = None;
945    for e in events {
946        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
947            let at = e.created_at.as_secs();
948            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
949                best = Some((at, l));
950            }
951        }
952    }
953    Ok(best.map(|(_, l)| l))
954}
955
956/// The creator's LIVE link-signer pubkeys for one community — the Registry's
957/// content (CORD-05 §5), derived from the stored link secrets.
958///
959/// Live means neither tombstoned nor EXPIRED. An expired link cannot be joined
960/// (`InviteBundle::expired`, CORD-05 §1), so leaving it in the Registry states
961/// a door that isn't there: the aggregate never empties, the community reads
962/// Public forever, and every gate hanging off that reading silently inverts.
963fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
964    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
965    list.entries
966        .iter()
967        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
968        .filter(|e| e.expires_at.is_none_or(|exp| now_ms <= exp))
969        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
970        .collect()
971}
972
973/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
974/// community — so members fold it into the Public/Private source of truth (a
975/// non-empty aggregate = Public).
976async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, __session: &std::sync::Arc<crate::db::Session>, live_signers: &[PublicKey]) -> Result<(), String> {
977    let my_pk = me_pk()?;
978    let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
979    let content = invite::build_registry_content(live_signers);
980    publish_control_edition(transport, community, vsk::INVITE_LINKS, &eid, &content).await?;
981    // Refresh the cache from the PLANE, not from `live_signers`: the column aggregates
982    // every creator, so writing only mine would clobber theirs, and a union could never
983    // shrink — retiring the last link would leave the community reading Public forever.
984    refresh_invite_registry_cache(transport, community).await;
985    Ok(())
986}
987
988/// Re-fold the whole invite Registry and cache it, so Public/Private stays a sync
989/// LOCAL read. Silent no-op when the plane can't be read whole — a partial fold
990/// would under-state Public, leaving a live link open behind a ban.
991async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
992    crate::db::scoped(async move {
993        let Ok(owner) = community.owner() else { return };
994        let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
995        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
996        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
997            .unwrap_or_default()
998            .into_iter()
999            .filter(|(_, f)| f.0 == community.root_epoch.0)
1000            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1001            .collect();
1002        let authority = fold_authority(community, &editions, &floors);
1003        let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
1004        let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
1005        let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
1006    })
1007    .await
1008}
1009
1010/// Record a freshly-minted public link across the creator's devices: append it to the
1011/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
1012async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
1013    crate::db::scoped(async move {
1014        let session = crate::db::current_session();
1015        let signer = crate::signer::active_signer()?;
1016        let my_pk = me_pk()?;
1017        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1018        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
1019        // Err aborts the sync half (the link's bundle already published durably;
1020        // a retry re-records it) — an unreachable relay set must never be mistaken
1021        // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
1022        // creator's honest first list.
1023        let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
1024        if !list.entries.iter().any(|e| e.token == token_hex) {
1025            list.entries.push(invite::InviteEntry {
1026                token: token_hex,
1027                signer_sk: minted.link_signer.secret_key().to_secret_hex(),
1028                community_id: cid_hex.clone(),
1029                url: minted.url.clone(),
1030                label: minted.label.clone(),
1031                created_at: now_ms() / 1000,
1032                expires_at: minted.expires_at_ms,
1033                epoch: Some(community.root_epoch.0),
1034                extra: Default::default(),
1035            });
1036        }
1037        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1038        transport.publish(&event, &community.relays).await?;
1039        let signers = live_signers_for(&list, &cid_hex, now_ms());
1040        publish_invite_registry(transport, community, &session, &signers).await
1041    })
1042    .await
1043}
1044
1045/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
1046/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
1047/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
1048/// LAST live link empties the Registry → the community reads Private (a Refounding is
1049/// the owner's separate read-cut).
1050pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
1051    crate::db::scoped(async move {
1052        let session = crate::db::current_session();
1053        let signer = crate::signer::active_signer()?;
1054        let my_pk = me_pk()?;
1055        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1056        let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1057        let entry = list.entries.iter().find(|e| e.token == token_hex && e.community_id == cid_hex).cloned();
1058        let Some(entry) = entry else {
1059            // The list is OURS and was positively fetched; a token it neither vends is a
1060            // husk — a local row kept past a revocation or privatize performed elsewhere
1061            // (another device, Armada). The row carries no signer_sk, so no revocation
1062            // can EVER be built from it: erroring here just leaves an entry the owner can
1063            // neither use nor remove. Pruning the local record is the only completion.
1064            // (Ok(None) above still errors — an unreadable list is not proof of absence.)
1065            let tombstoned = list.tombstones.iter().any(|t| t.token == token_hex && t.community_id == cid_hex);
1066            crate::log_warn!(
1067                "[Invites] link {} is not in the fetched invite list ({}) — pruning the local record",
1068                &token_hex[..token_hex.len().min(8)],
1069                if tombstoned { "already tombstoned" } else { "retired elsewhere" }
1070            );
1071            let _ = crate::db::community::delete_public_invite(token_hex);
1072            return Ok(());
1073        };
1074        // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
1075        let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1076        let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1077        transport.publish_durable(&revocation, &community.relays).await?;
1078        // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
1079        list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1080        list.entries.retain(|e| e.token != token_hex);
1081        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1082        transport.publish(&event, &community.relays).await?;
1083        let signers = live_signers_for(&list, &cid_hex, now_ms());
1084        // Does anyone ELSE still vend a live link? Folded BEFORE our own registry
1085        // republish: a fold taken after it would race relay propagation and read our
1086        // own stale (pre-revoke) registry, so the privatize decision would flip to
1087        // "still public" almost every time. Links are per-creator (CORD-05 §5), so
1088        // only the other creators' sets matter here — ours is `signers`.
1089        // (v1 parity: `revoke_public_invite`'s B1 fix refreshes the aggregate first
1090        // for exactly this reason.)
1091        let others_vend = if signers.is_empty() {
1092            match fetch_control_plane_whole(transport, community).await {
1093                Some(editions) => {
1094                    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1095                        .unwrap_or_default()
1096                        .into_iter()
1097                        .filter(|(_, f)| f.0 == community.root_epoch.0)
1098                        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1099                        .collect();
1100                    let owner_hex = community.owner().map(|o| o.to_hex()).unwrap_or_default();
1101                    let authority = fold_authority(community, &editions, &floors);
1102                    live_invite_link_sets(community.id(), &owner_hex, &editions, &authority, &floors)
1103                        .iter()
1104                        .any(|s| s.creator_hex != my_pk.to_hex())
1105                }
1106                // Unreadable plane: assume someone else still vends. Rotating on a
1107                // guess would burn an epoch (and strand pre-split clients) over a
1108                // transport blip; the community simply stays public until a read
1109                // that succeeds says otherwise.
1110                None => true,
1111            }
1112        } else {
1113            false
1114        };
1115        publish_invite_registry(transport, community, &session, &signers).await?;
1116        let _ = crate::db::community::delete_public_invite(token_hex);
1117        // CORD-06 §3: converting a Public Community to Private is a Refounding
1118        // trigger. Revoking only stops NEW acquisitions — everyone who already
1119        // fetched the bundle keeps the `community_root` until it rolls, so without
1120        // this the community reads Private while every past link-holder retains
1121        // read access forever. (v1 does this in `revoke_public_invite`; v2 shipped
1122        // without it.)
1123        //
1124        // Best-effort and retried, NEVER the revocation's verdict: the tombstone has
1125        // already landed, and reporting failure here would read as "the link is
1126        // still live" — the opposite of the truth — while a retry would fail anyway
1127        // (its list entry is gone). A Refounding needs BAN, so a link creator who
1128        // holds only CREATE_INVITE legitimately can't rotate; that surfaces as a
1129        // logged warning, not a broken revoke.
1130        if signers.is_empty() && !others_vend {
1131            let mut rotated = false;
1132            for attempt in 0..3u8 {
1133                match refound_community(transport, community, &[]).await {
1134                    Ok(_) => {
1135                        rotated = true;
1136                        break;
1137                    }
1138                    Err(e) if attempt == 2 => {
1139                        crate::log_warn!(
1140                            "[v2] privatizing {} could not rotate the base key ({e}); the community reads Private but everyone who took a link keeps read access until it rotates",
1141                            &cid_hex[..8.min(cid_hex.len())]
1142                        );
1143                        crate::emit_event(
1144                            "community_privatize_rotation_failed",
1145                            &serde_json::json!({ "community_id": cid_hex, "error": e }),
1146                        );
1147                    }
1148                    Err(_) => continue,
1149                }
1150            }
1151            if rotated {
1152                crate::log_info!("[v2] {} privatized: base key rotated so link-joined readers are cut off", &cid_hex[..8.min(cid_hex.len())]);
1153            }
1154        }
1155        Ok(())
1156    })
1157    .await
1158}
1159
1160// ── Severing containment (CORD-06 Severing Refounding) ──────────────────────
1161
1162/// Persisted sever marks for a community: `root_epoch -> rotation at_secs`.
1163/// A mark means "epoch E was a containment": link severance retries against it
1164/// on every follow pass, and a rescue extension of that hop re-stamps the tag.
1165pub(crate) fn sever_marks(cid_hex: &str) -> std::collections::BTreeMap<u64, u64> {
1166    crate::db::settings::get_sql_setting(format!("v2_sever:{cid_hex}"))
1167        .ok()
1168        .flatten()
1169        .and_then(|v| serde_json::from_str(&v).ok())
1170        .unwrap_or_default()
1171}
1172
1173/// Who minted each epoch: `root_epoch -> rotator hex`. CORD-02 §5 honors a
1174/// Guestbook snapshot "only from the npub whose Refounding minted that epoch",
1175/// so the fold has to remember which npub that was — the owner is only the
1176/// answer when the owner did the refounding.
1177pub(crate) fn epoch_minters(cid_hex: &str) -> std::collections::BTreeMap<u64, String> {
1178    crate::db::settings::get_sql_setting(format!("v2_minter:{cid_hex}"))
1179        .ok()
1180        .flatten()
1181        .and_then(|v| serde_json::from_str(&v).ok())
1182        .unwrap_or_default()
1183}
1184
1185/// Remember the rotator that minted `epoch`. Recorded at ADOPTION (and by the
1186/// refounder for its own mint), so it is always an npub this device verified as
1187/// an authorized rotator — never one a snapshot claims for itself.
1188pub(crate) fn record_epoch_minter(cid_hex: &str, epoch: u64, rotator_hex: &str) {
1189    let mut m = epoch_minters(cid_hex);
1190    if m.get(&epoch).is_some_and(|held| held == rotator_hex) {
1191        return;
1192    }
1193    m.insert(epoch, rotator_hex.to_string());
1194    if let Ok(v) = serde_json::to_string(&m) {
1195        let _ = crate::db::settings::set_sql_setting(format!("v2_minter:{cid_hex}"), v);
1196    }
1197}
1198
1199/// Record a severed rotation. Idempotent; marks only accumulate — the row is a
1200/// few bytes and clearing one would let a failed severance read as done (B4).
1201pub(crate) fn add_sever_mark(cid_hex: &str, epoch: u64, at_secs: u64) {
1202    let mut marks = sever_marks(cid_hex);
1203    marks.insert(epoch, at_secs);
1204    if let Ok(v) = serde_json::to_string(&marks) {
1205        let _ = crate::db::settings::set_sql_setting(format!("v2_sever:{cid_hex}"), v);
1206    }
1207}
1208
1209/// Sever MY OWN condemned public links: revoke (not refresh) every live link of
1210/// mine for this community whose minted-at epoch sits below the highest severed
1211/// epoch. The epoch is the discriminator — deterministic on every device, so a
1212/// second device adopting the containment late can never eat the fresh link the
1213/// owner minted to re-open (it carries the post-raid epoch), while a link minted
1214/// mid-raid by a not-yet-adopted device carries the raid-era epoch and dies.
1215/// Batched: every tombstone publishes, then ONE 13303 republish and ONE registry
1216/// edition for the whole set. Deliberately WITHOUT `revoke_public_link`'s
1217/// last-link refound escalation: every caller here sits inside a rotation
1218/// (refound or follow), where that escalation self-deadlocks on `follow_lock` —
1219/// and the severed rotation IS the read-cut the escalation exists to provide.
1220/// Returns the number revoked; `Ok(0)` when there is nothing of mine to sever.
1221async fn sever_own_links<T: Transport + ?Sized>(
1222    transport: &T,
1223    community: &CommunityV2,
1224) -> Result<usize, String> {
1225    crate::db::scoped(async move {
1226        let session = crate::db::current_session();
1227        let signer = crate::signer::active_signer()?;
1228        let my_pk = me_pk()?;
1229        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1230        let Some(condemn_below) = sever_marks(&cid_hex).keys().copied().max() else {
1231            return Ok(0); // no containment on record — nothing is condemned.
1232        };
1233        // Local fast-path: the folded Registry names every creator with a live
1234        // link, and my own mints also leave local rows. Neither names me → no
1235        // 13303 fetch; a plain member pays nothing here.
1236        let maybe_mine = crate::db::community::invite_link_set_creators(&cid_hex)
1237            .map(|cs| cs.contains(&my_pk.to_hex()))
1238            .unwrap_or(true)
1239            || !crate::db::community::list_public_invites(&cid_hex).unwrap_or_default().is_empty();
1240        if !maybe_mine {
1241            return Ok(0);
1242        }
1243        let Some(mut list) = fetch_invite_list(transport, &community.relays).await? else {
1244            return Ok(0); // no list ever published — nothing of mine exists.
1245        };
1246        let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1247        let victims: Vec<invite::InviteEntry> = list
1248            .entries
1249            .iter()
1250            .filter(|e| e.community_id == cid_hex && !dead.contains(e.token.as_str()) && e.epoch.unwrap_or(0) < condemn_below)
1251            .cloned()
1252            .collect();
1253        if victims.is_empty() {
1254            return Ok(0);
1255        }
1256        let mut revoked_tokens: Vec<String> = Vec::with_capacity(victims.len());
1257        for entry in &victims {
1258            // One corrupt entry must not wedge the batch (and with it the
1259            // community's Public/Private reading) forever: tombstone the husk —
1260            // no revocation can ever be built from it anyway.
1261            match Keys::parse(&entry.signer_sk) {
1262                Ok(link_signer) => {
1263                    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1264                    transport.publish_durable(&revocation, &community.relays).await?;
1265                }
1266                Err(_) => crate::log_warn!("[Sever] link {} carries a malformed signer — tombstoning the husk", &entry.token[..entry.token.len().min(8)]),
1267            }
1268            list.tombstones.push(invite::InviteTombstone {
1269                token: entry.token.clone(),
1270                community_id: cid_hex.clone(),
1271                extra: Default::default(),
1272            });
1273            revoked_tokens.push(entry.token.clone());
1274        }
1275        list.entries.retain(|e| !revoked_tokens.contains(&e.token));
1276        let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1277        transport.publish(&event, &community.relays).await?;
1278        let signers = live_signers_for(&list, &cid_hex, now_ms());
1279        publish_invite_registry(transport, community, &session, &signers).await?;
1280        for token in &revoked_tokens {
1281            let _ = crate::db::community::delete_public_invite(token);
1282        }
1283        crate::log_info!("[Sever] revoked {} own public link(s) (containment)", revoked_tokens.len());
1284        Ok(revoked_tokens.len())
1285    })
1286    .await
1287}
1288
1289/// The follow-pass severance retry (B4): while a sever mark exists and I still
1290/// hold live links at-or-below its line, re-attempt. A miss here is load-bearing
1291/// — a stale registry keeps the community reading Public, which silently skips
1292/// every future ban's refound — so this runs on every follow until it drains.
1293pub(crate) async fn retry_pending_severance<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) {
1294    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1295    if sever_marks(&cid_hex).is_empty() {
1296        return;
1297    }
1298    if let Err(e) = sever_own_links(transport, community).await {
1299        crate::log_warn!("[Sever] pending link severance retry failed (will retry next follow): {e}");
1300    }
1301}
1302
1303/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
1304/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
1305/// coordinate with the CURRENT community state, so a link shared once keeps working
1306/// across rotations. Best-effort.
1307pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1308    crate::db::scoped(async move {
1309        let session = crate::db::current_session();
1310        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1311        // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
1312        // Err — the caller (a post-refounding refresh) must be able to retry, or live
1313        // links keep serving the PRE-refound root and new joiners land on the dead
1314        // epoch. A genuinely-empty list is Ok (nothing to refresh).
1315        let signer = crate::signer::active_signer()?;
1316        let my_pk = me_pk()?;
1317        let query = Query {
1318            kinds: vec![super::kind::INVITE_LIST],
1319            authors: vec![my_pk.to_hex()],
1320            limit: Some(4),
1321            ..Default::default()
1322        };
1323        let events = transport.fetch(&query, &community.relays).await?;
1324        let mut best: Option<(u64, invite::InviteList)> = None;
1325        for e in events {
1326            if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1327                let at = e.created_at.as_secs();
1328                if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1329                    best = Some((at, l));
1330                }
1331            }
1332        }
1333        let Some((_, list)) = best else {
1334            return Ok(());
1335        };
1336        let creator = my_pk;
1337        let now = now_ms();
1338        let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1339        // A containment condemns every link below its epoch. Refreshing one would
1340        // RE-ARM the exact door the severing rotation closed (with the new root,
1341        // no less) — and once re-armed, no retry could tell it from a legitimate
1342        // link. Skip here; the severance retry owns their burial.
1343        let condemn_below = sever_marks(&cid_hex).keys().copied().max().unwrap_or(0);
1344        for entry in &list.entries {
1345            if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1346                continue;
1347            }
1348            if entry.epoch.unwrap_or(0) < condemn_below {
1349                continue;
1350            }
1351            // An expired link can't be joined, so refreshing it just re-states a
1352            // door that isn't there (CORD-05 §1/§5).
1353            if entry.expires_at.is_some_and(|exp| now > exp) {
1354                continue;
1355            }
1356            let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1357            let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1358            let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1359            let bundle_key = super::derive::invite_bundle_key(&token);
1360            if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1361                let _ = transport.publish_durable(&event, &community.relays).await;
1362            }
1363        }
1364        // Republish the Registry from the same pruned view. Expiry is the one way a
1365        // link dies with no user action, so without a heal point here the aggregate
1366        // never empties and the community reads Public long after its last door
1367        // shut (CORD-05 §5). Idempotent when nothing lapsed.
1368        //
1369        // Only for a creator who actually minted here: one Invite List spans every
1370        // community, so a member holding links ELSEWHERE would otherwise publish an
1371        // empty Registry edition into this one on every rotation they adopt — a
1372        // control-plane write, and a version bump, for a coordinate they never owned.
1373        let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1374        if !mine_here {
1375            return Ok(());
1376        }
1377        let signers = live_signers_for(&list, &cid_hex, now);
1378        let _ = publish_invite_registry(transport, community, &session, &signers).await;
1379        Ok(())
1380    })
1381    .await
1382}
1383
1384/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1385/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1386/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1387/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1388pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1389    let Ok(owner) = community.owner() else { return false };
1390    // Truncation fails toward Public: over-stating it only makes a caller take the
1391    // stronger remedy (privatise + re-found + reissue), while under-stating it
1392    // leaves a live link open behind a ban.
1393    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1394    let cid = community.id();
1395    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1396    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1397        .unwrap_or_default()
1398        .into_iter()
1399        .filter(|(_, f)| f.0 == community.root_epoch.0)
1400        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1401        .collect();
1402    let authority = fold_authority(community, &editions, &floors);
1403    !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1404}
1405
1406/// Page the WHOLE control plane, not the newest window: a registry pushed out of a
1407/// single page reads as retired, and any member can push it out since the plane key
1408/// comes from the community root they hold. `None` = it could NOT be read whole
1409/// (transport failure, same-second wall, pager depth), so a caller must not mistake
1410/// an empty fold for absence.
1411async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1412    let control = control::ControlPlane::of(community);
1413    let mut editions: Vec<ParsedEdition> = Vec::new();
1414    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1415    let mut oldest: Option<u64> = None;
1416    let mut until: Option<u64> = None;
1417    for page in 0..COMPACT_MAX_PAGES {
1418        // Full, not Quorum: `None` here means "could not be read whole", and a
1419        // majority that simply lacks another creator's Registry answers as fast as
1420        // one that has it — so a Quorum read returns Some(empty) and the plane reads
1421        // link-free. The ban path turns that into a Refounding of a PUBLIC community,
1422        // which CORD-05 §5 forbids: the link refresh re-posts the bundle behind the
1423        // same URL, so the rotation severs nothing and buries foreign-link joiners.
1424        let query = Query {
1425            kinds: vec![stream::KIND_WRAP],
1426            authors: vec![control.pk_hex()],
1427            until,
1428            limit: Some(FOLLOW_PAGE),
1429            evidence: crate::community::transport::Evidence::Full,
1430            ..Default::default()
1431        };
1432        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1433        let mut fresh = 0usize;
1434        for w in &wraps {
1435            if !seen_wraps.insert(w.id) {
1436                continue;
1437            }
1438            fresh += 1;
1439            let at = w.created_at.as_secs();
1440            if oldest.is_none_or(|o| at < o) {
1441                oldest = Some(at);
1442            }
1443            if let Ok((ed, _)) = control.open(w) {
1444                editions.push(ed);
1445            }
1446        }
1447        if fresh == 0 {
1448            if wraps.len() >= FOLLOW_PAGE {
1449                return None; // same-second wall: the plane can't be read whole
1450            }
1451            return Some(editions);
1452        }
1453        until = oldest;
1454        if page + 1 == COMPACT_MAX_PAGES {
1455            return None;
1456        }
1457    }
1458    Some(editions)
1459}
1460
1461/// The live link coordinates PER AUTHORISED CREATOR across every Registry (vsk-8);
1462/// non-empty ⇒ the Community is Public, and the per-creator split is what drives
1463/// "X has N active invite links". Pure over an already-fetched edition set so the
1464/// on-demand probe and the control follow fold it identically.
1465fn live_invite_link_sets(
1466    cid: &crate::community::CommunityId,
1467    owner_hex: &str,
1468    editions: &[ParsedEdition],
1469    authority: &AuthoritySet,
1470    floors: &Floors,
1471) -> Vec<crate::db::community::InviteLinkSetRow> {
1472    use crate::community::roles::Permissions;
1473    use std::collections::BTreeMap;
1474    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1475    for e in editions {
1476        if e.vsk == vsk::INVITE_LINKS {
1477            by_eid.entry(e.entity_id).or_default().push(e);
1478        }
1479    }
1480    let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1481    for (eid, group) in &by_eid {
1482        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1483        // picks an equal-version winner author-blind (lowest inner id, which an
1484        // author can grind), so folding first would let any member occupy the head
1485        // slot and have the whole registry dropped by the check below — silently
1486        // retiring a live invite link, i.e. flipping the community to Private.
1487        let authed: Vec<&ParsedEdition> = group
1488            .iter()
1489            .copied()
1490            .filter(|p| {
1491                let author = p.author.to_hex();
1492                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1493                !authority.banned.contains(&author)
1494                    && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1495                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1496            })
1497            .collect();
1498        if authed.is_empty() {
1499            continue;
1500        }
1501        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1502        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1503        if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1504            if signers.is_empty() {
1505                continue; // a creator who retired every link is absent, not a zero row
1506            }
1507            sets.push(crate::db::community::InviteLinkSetRow {
1508                creator_hex: authed[hi].author.to_hex(),
1509                locators: signers.iter().map(|p| p.to_hex()).collect(),
1510            });
1511        }
1512    }
1513    sets
1514}
1515
1516/// Flatten per-creator sets into the aggregate the `invite_registry` column holds.
1517fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1518    let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1519    flat.sort();
1520    flat.dedup();
1521    flat
1522}
1523
1524/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1525/// delivered community_root is genuinely the owner's, persist the community, and
1526/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1527/// paths. Takes the caller's `std::sync::Arc<crate::db::Session>` (captured BEFORE any network fetch the
1528/// caller did) so the whole operation stays with one account across that I/O.
1529async fn accept_bundle<T: Transport + ?Sized>(
1530    transport: &T,
1531    bundle: &CommunityInvite,
1532    invited_by: Option<PublicKey>,
1533    announce_join: bool,
1534) -> Result<CommunityV2, String> {
1535    crate::db::scoped(async move {
1536        let signer = crate::signer::active_signer()?;
1537        let my_pk = me_pk()?;
1538        let at_ms = now_ms();
1539        // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1540        if bundle.expired(at_ms) {
1541            return Err("this invite has expired".to_string());
1542        }
1543        // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1544        let community = CommunityV2::from_bundle(bundle, at_ms)?;
1545        // Captured before the save below: a re-accept of a held community must not
1546        // re-announce a membership this account already declared.
1547        let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1548
1549        // Authenticate the delivered community_root before trusting it. The owner
1550        // commitment proves WHO the owner is, but community_root (and channel keys) are
1551        // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1552        // with an attacker-chosen root and silently partition the joiner onto planes
1553        // only the attacker controls. Requiring the owner's genesis to open under the
1554        // delivered root closes that eclipse; also reconciles channel classification.
1555        // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1556        // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1557        let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1558            v.session.is_live()
1559                && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1560                && v.community_id == community.id().0
1561                && v.community_root == community.community_root
1562        });
1563        let (community, join_heads, join_banlist, join_pins, join_banlist_content, join_roles, join_authority_heads) =
1564            match handoff {
1565                Some(v) => {
1566                    let mut c = v.folded;
1567                    // The preview holds no acquisition time — stamp the JOIN's.
1568                    c.created_at_ms = at_ms;
1569                    (c, v.heads, v.banned, v.pins, v.banlist_persist, v.roles, v.authority_heads)
1570                }
1571                None => {
1572                    let vj = verify_owner_root_and_reconcile(transport, community).await?;
1573                    (vj.community, vj.heads, vj.banned, vj.pins, vj.banlist_persist, vj.roles, vj.authority_heads)
1574                }
1575            };
1576
1577        // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1578        if is_dissolved(transport, &community).await {
1579            return Err(ERR_DISSOLVED.to_string());
1580        }
1581
1582        // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1583        // community whose authorized banlist names it — before the Guestbook Join publishes
1584        // and before any local write. Every door funnels through here (direct invite, parked,
1585        // public link, migration), so none of them needs its own exclusion.
1586        if join_banlist.contains(&my_pk.to_hex()) {
1587            return Err("you are banned from this community".to_string());
1588        }
1589
1590        // The account must not have swapped since the guard was captured (which was
1591        // before any fetch the caller / the verify above performed) — else we'd write
1592        // A's join into B.
1593        // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1594        // community row lands (floors-then-state, so a mid-seed error can't leave saved
1595        // state outrunning its floor); the first post-join follow then can't persist a
1596        // state below what this join already showed.
1597        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1598        for h in join_heads.iter().chain(join_authority_heads.iter()) {
1599            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
1600        }
1601        crate::db::community::save_community_v2(&community)?;
1602        // Archive the joined root at its epoch, so this member reads Public-channel
1603        // history from their join epoch onward across later Refoundings (CORD-03 §3).
1604        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1605        // Same for each granted Private-channel key: the archive is what lets its
1606        // history stay readable after the channel rotates away from this key.
1607        for ch in &community.channels {
1608            if let (true, Some(key)) = (ch.private, ch.key) {
1609                let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1610            }
1611        }
1612        // Persist what the verification walk already folded: the control plane WAS
1613        // read, so making the fresh member wait for the follow worker's queued
1614        // re-walk to see pins or enforce bans is pure lag (it read 20-30s on a busy
1615        // queue). The system "modified the Pins" line still arrives via that first
1616        // follow — a join backfills state, it doesn't announce edits.
1617        if let Some((banned_list, version)) = &join_banlist_content {
1618            let _ = crate::db::community::set_community_banlist(&cid_hex, banned_list, *version as i64);
1619        }
1620        // The ROSTER, same rule as the banlist and pins above: the walk already
1621        // paid for it. Persisted BEFORE the channels register, because the first
1622        // history ingest reads it to honor moderation hides — an empty roster
1623        // authorizes nobody, so every admin-hidden message would render to the
1624        // fresh member until the follow worker's queued re-walk landed.
1625        if let Some((roles, at)) = &join_roles {
1626            let _ = crate::db::community::set_community_roles(&cid_hex, roles, *at);
1627        }
1628        for (channel_hex, content, version, _author, _at) in &join_pins {
1629            if matches!(
1630                crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64),
1631                Ok(true)
1632            ) {
1633                crate::emit_event(
1634                    "community_pins_updated",
1635                    &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
1636                );
1637            }
1638        }
1639
1640        // Announce our Guestbook Join, echoing the invite attribution when present.
1641        // Only an ACTUAL join speaks: a re-accept of a held community, or a
1642        // cross-device key sync (announce_join=false), is not a membership event —
1643        // the account's original Join already stands in the guestbook, and every
1644        // re-publish renders as "<user> has joined" spam for the whole community.
1645        if announce_join && !already_held {
1646            let attribution = invited_by
1647                .map(|p| p.to_hex())
1648                .or_else(|| bundle.creator_npub.clone())
1649                .zip(Some(bundle.label.clone().unwrap_or_default()));
1650            let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1651            let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1652            let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1653            if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1654                let _ = transport.publish(&join_wrap, &community.relays).await;
1655            }
1656        }
1657
1658        // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1659        // happy path; anything else hands off to the durable retry, because an unrecorded
1660        // join is what strands a community behind a stale tombstone.
1661        //
1662        // Only for a REAL join. `announce_join == false` is the list-sync adoption path,
1663        // and those entries were just read FROM this list — republishing it re-records what
1664        // it already holds. A device adopting N entries would otherwise rebuild and publish
1665        // the whole document N times, concurrently, every copy identical, on the boot path
1666        // that is already the slowest thing the app does.
1667        if announce_join {
1668            match republish_community_list(transport, Some(community.id())).await {
1669                Ok(true) => {}
1670                Ok(false) => republish_community_list_durable(Some(*community.id())),
1671                Err(e) => {
1672                    crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1673                    republish_community_list_durable(Some(*community.id()));
1674                }
1675            }
1676        }
1677        Ok(community)
1678    })
1679    .await
1680}
1681
1682/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1683/// channel classification from the owner's editions. `community_id` commits only
1684/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1685/// every synced Community List) — so a forged invite can present a real community's
1686/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1687/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1688/// owner commitment still "verifies". The defense: the owner's genesis metadata
1689/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1690/// attacker can't forge the owner's seal — so its presence on the control plane
1691/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1692/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1693/// re-wraps heads with their original signatures), so the anchor there is the
1694/// community-bound metadata head plus any owner-signed edition under the same root.
1695/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1696/// On success, folds the owner's authoritative editions to heal a bundle that
1697/// misclassified a channel.
1698/// Everything the join-time verification walk folded, handed to the accept path so
1699/// what the join VERIFIED is also what it PERSISTS. The walk already paid for the
1700/// control plane read; deferring persistence to the first post-join follow re-pays
1701/// the network cost and leaves the fresh member pins-blind and banlist-blind for
1702/// the whole follow-queue delay.
1703struct VerifiedJoin {
1704    community: CommunityV2,
1705    heads: Vec<FoldedHead>,
1706    /// The join-gate banlist (authorized fold over the any-author edition set).
1707    banned: std::collections::BTreeSet<String>,
1708    /// Pin List heads folded under the FULL join-time authority (curators included,
1709    /// same §5 gate as any later follow): `(channel_hex, content, version, author_npub, created_at)`.
1710    pins: Vec<(String, String, u64, String, u64)>,
1711    /// The authorized banlist head's content + version, for local persistence.
1712    banlist_persist: Option<(Vec<String>, u64)>,
1713    /// The FULL authorized roster (admins included) from the same walk, with the
1714    /// newest roster edition's `created_at`. `None` when the fold saw a gap.
1715    ///
1716    /// This is the one authority artifact the join used to throw away — and a
1717    /// fresh member whose roster is empty until the follow worker's re-walk
1718    /// renders every moderator-hidden message in the meantime: the hide gate
1719    /// reads the stored roster, and an empty roster authorizes nobody.
1720    roles: Option<(crate::community::roles::CommunityRoles, i64)>,
1721    /// Role/Grant/Banlist heads folded under the full authority, so an
1722    /// ADMIN-signed grant seeds the same refuse-downgrade floor an owner-signed
1723    /// one does.
1724    authority_heads: Vec<FoldedHead>,
1725}
1726
1727async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1728    transport: &T,
1729    community: CommunityV2,
1730) -> Result<VerifiedJoin, String> {
1731    let owner = community.owner()?;
1732    let control = control::ControlPlane::of(&community);
1733    let control_pk = control.pk_hex();
1734
1735    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1736    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1737    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1738    // satisfy the gate and the control plane reads back empty. Register this
1739    // community's stream keys + start the challenge responder so the fetch below
1740    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1741    super::streamauth::prime(&community);
1742
1743    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1744    // community_id`) at the root-derived control plane. The genesis eid pins it to
1745    // THIS community, and it lives ONLY under the real root — so a forged root can't
1746    // produce one: an edition's seal carries no community binding, but another
1747    // community's genesis has a different eid, and this community's own genesis is
1748    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1749    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1750    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1751    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1752    // binding the root into community_id (protocol, deferred).
1753    //
1754    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1755    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1756    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1757    // an open partial window misses a genesis on a lagging relay (routine over
1758    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1759    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1760    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1761    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1762    // that buries the genesis past the walk is the deferred protocol residual.
1763    const PAGE: usize = 500;
1764    const MAX_PAGES: usize = 4;
1765    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1766    let mut editions: Vec<ParsedEdition> = Vec::new();
1767    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1768    let mut found_genesis = false;
1769    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1770    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1771    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1772    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1773    // at least one owner-signed edition opened under this root. A non-member forger
1774    // can produce neither; the sibling-community rewrap residual this reopens is the
1775    // same class the spec defers to root-in-id binding.
1776    let mut compacted_metadata = false;
1777    crate::log_debug!(
1778        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1779        &control_pk[..12], community.root_epoch, community.relays
1780    );
1781    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1782        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1783    };
1784    for attempt in 0..2 {
1785        editions.clear();
1786        all_editions.clear();
1787        compacted_metadata = false;
1788        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1789        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1790        for page_no in 0..MAX_PAGES {
1791            let query = Query {
1792                kinds: vec![stream::KIND_WRAP],
1793                authors: vec![control_pk.clone()],
1794                until,
1795                limit: Some(PAGE),
1796                evidence: crate::community::transport::Evidence::Full,
1797                ..Default::default()
1798            };
1799            let wraps = transport.fetch(&query, &community.relays).await?;
1800            crate::log_trace!(
1801                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1802                attempt, page_no, wraps.len()
1803            );
1804            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1805            // siblings at a page boundary (and the genesis with them); re-served
1806            // boundary events are free, and no-new-events means exhausted.
1807            let mut oldest = u64::MAX;
1808            let mut fresh = 0usize;
1809            for w in &wraps {
1810                if !seen_wraps.insert(w.id) {
1811                    continue;
1812                }
1813                fresh += 1;
1814                oldest = oldest.min(w.created_at.as_secs());
1815                if let Ok((ed, _)) = control.open(w) {
1816                    crate::log_trace!(
1817                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1818                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1819                        ed.author == owner, w.created_at.as_secs()
1820                    );
1821                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1822                        if ed.author == owner {
1823                            found_genesis = true;
1824                        } else {
1825                            compacted_metadata = true;
1826                        }
1827                    }
1828                    if ed.author == owner {
1829                        editions.push(ed.clone());
1830                    }
1831                    // Any-author set for the join-time authority fold below: the banlist head
1832                    // may be admin-signed, and its authority chains to the owner regardless.
1833                    all_editions.push(ed);
1834                }
1835            }
1836            crate::log_debug!(
1837                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1838                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1839            );
1840            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1841                break; // authenticated, or the relay is exhausted.
1842            }
1843            until = Some(oldest);
1844        }
1845        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1846            break;
1847        }
1848        if attempt == 0 {
1849            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1850            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1851            // satisfy a stream-authors gate — and can land before the responder's
1852            // stream-key auth settles, reading the plane back EMPTY. Replay the
1853            // remembered challenges for every registered stream key, then walk once
1854            // more on the settled connection.
1855            if let Some(client) = crate::state::nostr_client() {
1856                super::streamauth::prime_auth(&client, &community.relays).await;
1857            }
1858        }
1859    }
1860    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1861        return Err(
1862            "could not verify this community from its relays (the invite may be forged, the relays are unreachable, or the control plane is being flooded); not joining"
1863                .to_string(),
1864        );
1865    }
1866    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1867    // entity). The heads this fold verified are returned for the caller to SEED as
1868    // the initial floor once the community row is saved — without that, the first
1869    // post-join follow would bootstrap floor-less and could persist a state BELOW
1870    // what this join already verified and showed.
1871    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1872    // above), and the owner is supreme — so owner-only authority suffices. The full
1873    // roster (admins) folds on the first post-join follow_control.
1874    let empty_floors = Floors::new();
1875    let authority = AuthoritySet::owner_only();
1876    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1877    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1878    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1879    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1880    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1881    let authority_full = fold_authority(&community, &all_editions, &empty_floors);
1882    // Pins under the FULL authority: a curator-authored Pin List is as valid at join
1883    // as on any later follow (same PIN_MESSAGES + citation gate, same owner-chained
1884    // roster). Only the pins are taken from this pass — the DOCUMENT adoption above
1885    // stays owner-only, its stricter envelope unchanged.
1886    let pins = apply_control_fold(&community, &all_editions, &empty_floors, &authority_full).pins_persist;
1887    // The newest roster edition's stamp, exactly as follow_control computes it, so
1888    // the follow's own freshness gate composes with a join-seeded roster.
1889    let newest_roster_at = all_editions
1890        .iter()
1891        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
1892        .map(|e| e.created_at as i64)
1893        .max()
1894        .unwrap_or(0);
1895    // An anchored walk read the plane back to genesis, so the window is complete
1896    // by construction; `gapped` still gates the persist for the same reason
1897    // follow_control's does — a withheld edition must not fold into a baseline.
1898    let roles = (!authority_full.gapped).then(|| (authority_full.roles.clone(), newest_roster_at));
1899    Ok(VerifiedJoin {
1900        community: fold.updated.unwrap_or(community),
1901        heads: fold.heads,
1902        banned: authority_full.banned,
1903        pins,
1904        banlist_persist: authority_full.banlist_persist,
1905        roles,
1906        authority_heads: authority_full.heads,
1907    })
1908}
1909
1910/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1911/// then run the shared accept path. The recipient's consent IS this call. No
1912/// network await precedes the accept, so the guard captured here suffices.
1913pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1914    let signer = crate::signer::active_signer()?;
1915    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1916    accept_bundle(transport, &bundle, Some(inviter), true).await
1917}
1918
1919/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1920/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1921/// bundle validation, then runs the shared accept path (which re-verifies the owner
1922/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1923/// Join attribution.
1924pub async fn accept_parked_invite<T: Transport + ?Sized>(
1925    transport: &T,
1926    bundle_json: &str,
1927    inviter_hex: Option<&str>,
1928) -> Result<CommunityV2, String> {
1929    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1930    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1931    accept_bundle(transport, &bundle, invited_by, true).await
1932}
1933
1934/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1935/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1936/// path, which re-verifies the owner root over the network and enforces the join-time ban
1937/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1938/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1939pub async fn accept_migration_material<T: Transport + ?Sized>(
1940    transport: &T,
1941    jm: &super::list::JoinMaterial,
1942) -> Result<CommunityV2, String> {
1943    let bundle = material_to_invite(jm);
1944    accept_bundle(transport, &bundle, None, true).await
1945}
1946
1947/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1948/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1949/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1950/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1951/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1952/// the newest valid Live by `created_at`. Read-only.
1953pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1954    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1955    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1956    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1957    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1958    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1959    let query = Query {
1960        kinds: vec![super::kind::INVITE_BUNDLE],
1961        authors: vec![parsed.link_signer.to_hex()],
1962        ..Default::default()
1963    };
1964    let relays = if parsed.bootstrap_relays.is_empty() {
1965        invite::stock_relays()
1966    } else {
1967        parsed.bootstrap_relays.clone()
1968    };
1969    // One bounded retry: a join fired while the pool is still warming (bootstrap
1970    // relays mid-handshake, routine during boot contention) reads back a transport
1971    // error, not an absent bundle. The pool add already happened on the first try,
1972    // so wait for a socket rather than guessing with a fixed sleep.
1973    let events = match transport.fetch(&query, &relays).await {
1974        Ok(evs) => evs,
1975        Err(_) => {
1976            wait_for_bootstrap_relay(&relays).await;
1977            transport.fetch(&query, &relays).await?
1978        }
1979    };
1980    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1981
1982    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1983    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1984    for event in &events {
1985        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1986            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1987            Ok(invite::BundleState::Live(bundle)) => {
1988                let at = event.created_at.as_secs();
1989                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1990                    newest_live = Some((at, *bundle));
1991                }
1992            }
1993            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1994        }
1995    }
1996    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1997}
1998
1999/// Wait — bounded — for ANY of the targets to report Connected before a retry:
2000/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
2001/// handshake takes under boot contention.
2002async fn wait_for_bootstrap_relay(relays: &[String]) {
2003    let Some(client) = crate::state::nostr_client() else { return };
2004    let deadline = crate::rt::time::Instant::now() + std::time::Duration::from_secs(8);
2005    loop {
2006        for url in relays {
2007            if let Ok(Some(relay)) = client.relay(url).await {
2008                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
2009                    return;
2010                }
2011            }
2012        }
2013        if crate::rt::time::Instant::now() >= deadline {
2014            return;
2015        }
2016        crate::rt::time::sleep(std::time::Duration::from_millis(400)).await;
2017    }
2018}
2019
2020/// The most recent owner-root verification a PREVIEW completed, handed to a join
2021/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
2022/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
2023/// a different delivered root never matches. The join's own bundle re-fetch is
2024/// untouched, so the revocation gate always runs live.
2025struct VerifiedPreview {
2026    session: std::sync::Arc<crate::db::Session>,
2027    at: web_time::Instant,
2028    community_id: [u8; 32],
2029    community_root: [u8; 32],
2030    folded: CommunityV2,
2031    heads: Vec<FoldedHead>,
2032    /// The join-time authorized banlist from the SAME verified walk — carried so the
2033    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
2034    banned: std::collections::BTreeSet<String>,
2035    /// Folded pins + banlist content from the same walk, so a preview-handoff join
2036    /// persists them exactly like a direct join.
2037    pins: Vec<(String, String, u64, String, u64)>,
2038    banlist_persist: Option<(Vec<String>, u64)>,
2039    roles: Option<(crate::community::roles::CommunityRoles, i64)>,
2040    authority_heads: Vec<FoldedHead>,
2041}
2042static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
2043const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
2044
2045/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
2046/// display metadata folded live from the Control Plane (a v2 bundle deliberately
2047/// carries no icon — the fold is the authority). Owner-root verification rides
2048/// the fold, so a forged-root link can't render a convincing preview; on a
2049/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
2050/// — the caller hasn't joined.
2051pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2052    let bundle = fetch_public_bundle(transport, url).await?;
2053    preview_bundle(transport, &bundle).await
2054}
2055
2056/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
2057/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
2058/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
2059/// it again regardless.
2060pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
2061    let community = CommunityV2::from_bundle(bundle, 0)?;
2062    match verify_owner_root_and_reconcile(transport, community.clone()).await {
2063        Ok(vj) => {
2064            let folded = vj.community;
2065            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
2066                session: crate::db::current_session(),
2067                at: web_time::Instant::now(),
2068                community_id: folded.id().0,
2069                community_root: folded.community_root,
2070                folded: folded.clone(),
2071                heads: vj.heads,
2072                banned: vj.banned,
2073                pins: vj.pins,
2074                banlist_persist: vj.banlist_persist,
2075                roles: vj.roles,
2076                authority_heads: vj.authority_heads,
2077            });
2078            Ok(folded)
2079        }
2080        Err(_) => Ok(community),
2081    }
2082}
2083
2084/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
2085pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
2086    crate::db::scoped(async move {
2087        // Held across the network fetch so the join stays with one account.
2088        let bundle = fetch_public_bundle(transport, url).await?;
2089        accept_bundle(transport, &bundle, None, true).await
2090    })
2091    .await
2092}
2093
2094/// Leave a community: publish a Guestbook Leave and tear down the local hold.
2095pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2096    crate::db::scoped(async move {
2097        let signer = crate::signer::active_signer()?;
2098        let my_pk = me_pk()?;
2099        let at_ms = now_ms();
2100        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2101        let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
2102        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
2103            let _ = transport.publish(&wrap, &community.relays).await;
2104        }
2105        // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete.
2106        // Best-effort, but never silent: an unpublished tombstone is what lets a stale
2107        // copy of the list rejoin this community on a later boot.
2108        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2109            crate::log_net_fail!("[CommunityList] leave tombstone failed ({}) — retrying in the background", e);
2110            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2111        }
2112        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2113        Ok(())
2114    })
2115    .await
2116}
2117
2118/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
2119/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
2120/// `can_kick`), so publishing without authority is inert. A kicked member may
2121/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
2122pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
2123    crate::db::scoped(async move {
2124        assert_current_root(community)?;
2125        let signer = crate::signer::active_signer()?;
2126        let my_pk = me_pk()?;
2127        // Fast local pre-check; readers re-verify independently.
2128        let authority = fetch_authority(transport, community).await;
2129        let owner_hex = community.owner()?.to_hex();
2130        if !authority.roles.can_act_on_member(
2131            &my_pk.to_hex(),
2132            Some(&owner_hex),
2133            &target.to_hex(),
2134            crate::community::roles::Permissions::KICK,
2135        ) {
2136            return Err("not authorized to kick this member".to_string());
2137        }
2138        // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
2139        // first, so the target's rank is gone before the departure lands. Without it a
2140        // kicked admin leaves the memberlist still holding every management bit, and
2141        // every client keeps honoring their control editions.
2142        //
2143        // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
2144        // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
2145        // target just keeps their rank until an authorized strip lands. Each layer
2146        // validates on its own rule, so a missing one is a weaker removal, never a
2147        // broken one. A strip we DO attempt and lose is a hard error: proceeding would
2148        // publish a directive we know leaves rank behind.
2149        let target_hex = target.to_hex();
2150        let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2151        let may_strip = authority.roles.can_act_on_member(
2152            &my_pk.to_hex(),
2153            Some(&owner_hex),
2154            &target_hex,
2155            crate::community::roles::Permissions::MANAGE_ROLES,
2156        );
2157        if holds_roles && may_strip {
2158            grant_roles(transport, community, target, Vec::new())
2159                .await
2160                .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
2161        }
2162        let at_ms = now_ms();
2163        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2164        // A Kick is an authority action, so it cites its Grant like any other
2165        // (CORD-02 §5 / CORD-04 §5).
2166        let citation = required_authority_citation(community, &my_pk)?;
2167        let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2168        let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
2169            .map_err(|e| e.to_string())?;
2170        transport.publish(&wrap, &community.relays).await?;
2171        Ok(())
2172    })
2173    .await
2174}
2175
2176/// What a [`kick_members`] sweep actually managed to do.
2177#[derive(Debug, Default)]
2178pub struct KickSweep {
2179    pub kicked: Vec<PublicKey>,
2180    /// Targets the caller has no standing over, with the reason. Collected rather than
2181    /// returned as an error: one unkickable member must not sink the rest of the wave.
2182    pub refused: Vec<(PublicKey, String)>,
2183    /// Publishes that failed on the wire — transient, and safe to retry.
2184    pub failed: Vec<PublicKey>,
2185}
2186
2187/// Kick many members in one sweep.
2188///
2189/// A Kick is an individual directive, unlike a Banlist edition which names every npub in
2190/// one document, so the wire cost here is inherently linear and there is nothing to
2191/// batch. What this saves is everything around it: [`kick_member`] folds the whole
2192/// control plane per call, so kicking a raid one at a time spends a paged network fetch
2193/// per target. Authority, citation and the plane key are resolved once and reused.
2194///
2195/// `on_progress(done, total)` fires after every target, refusals included, so a caller
2196/// can drive a counter through a sweep that takes minutes.
2197pub async fn kick_members<T: Transport + ?Sized>(
2198    transport: &T,
2199    community: &CommunityV2,
2200    targets: &[PublicKey],
2201    on_progress: &(dyn Fn(usize, usize) + Sync),
2202) -> Result<KickSweep, String> {
2203    crate::db::scoped(async move {
2204        assert_current_root(community)?;
2205        let mut out = KickSweep::default();
2206        if targets.is_empty() {
2207            return Ok(out);
2208        }
2209        let signer = crate::signer::active_signer()?;
2210        let my_pk = me_pk()?;
2211        let my_hex = my_pk.to_hex();
2212        let owner_hex = community.owner()?.to_hex();
2213        // ONCE. This is the paged control-plane fetch that makes the per-member path
2214        // unusable in bulk.
2215        let authority = fetch_authority(transport, community).await;
2216        let citation = required_authority_citation(community, &my_pk)?;
2217        let gb_group =
2218            super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2219
2220        let total = targets.len();
2221        for (i, target) in targets.iter().enumerate() {
2222            let target_hex = target.to_hex();
2223            let outranks = |perm: u64| {
2224                authority.roles.can_act_on_member(&my_hex, Some(&owner_hex), &target_hex, perm)
2225            };
2226            if !outranks(crate::community::roles::Permissions::KICK) {
2227                out.refused.push((*target, "you do not outrank this member".to_string()));
2228                on_progress(i + 1, total);
2229                continue;
2230            }
2231            // CORD-04 §6: a Kick is Role Removal THEN the directive. Only a role-holder
2232            // pays for the strip, so a wave of plain members never touches the control
2233            // plane at all. A strip we attempt and lose skips the target rather than
2234            // publishing a directive we know leaves rank behind.
2235            let holds_roles =
2236                authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
2237            if holds_roles && outranks(crate::community::roles::Permissions::MANAGE_ROLES) {
2238                if let Err(e) = grant_roles(transport, community, target, Vec::new()).await {
2239                    out.refused.push((*target, format!("could not strip roles before kicking: {e}")));
2240                    on_progress(i + 1, total);
2241                    continue;
2242                }
2243            }
2244            let at_ms = now_ms();
2245            let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
2246            match guestbook::seal_guestbook_rumor_signed(
2247                &signer,
2248                my_pk,
2249                &rumor,
2250                &gb_group,
2251                Timestamp::from_secs(at_ms / 1000),
2252            )
2253            .await
2254            {
2255                Ok((wrap, _)) => match transport.publish(&wrap, &community.relays).await {
2256                    Ok(_) => out.kicked.push(*target),
2257                    Err(_) => out.failed.push(*target),
2258                },
2259                Err(_) => out.failed.push(*target),
2260            }
2261            on_progress(i + 1, total);
2262        }
2263        Ok(out)
2264    })
2265    .await
2266}
2267
2268/// A community's folded, delegation-authorized authority — the on-demand read
2269/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
2270/// owner-seeded authorized roster (shared algebra with v1); `banned` the
2271/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
2272/// entity (floored locally but no head folded) before replacing it blind.
2273pub struct AuthorityView {
2274    pub roles: crate::community::roles::CommunityRoles,
2275    pub banned: std::collections::BTreeSet<String>,
2276    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
2277    pub gapped: bool,
2278    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
2279    pub floored: std::collections::BTreeSet<String>,
2280    /// Authority entities (role/grant/banlist) that folded a head this fetch.
2281    pub head_entities: std::collections::BTreeSet<String>,
2282    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
2283    pub banned_at: std::collections::BTreeMap<String, u64>,
2284}
2285
2286/// Fetch + fold the community's current authority (CORD-04), paging older like
2287/// `follow_control` while the fold is gapped so a busy control plane can't push
2288/// the roster off the newest window. A fetch failure degrades fail-safe:
2289/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
2290/// an outage, and a ban never lifts on withheld data.
2291pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
2292    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2293    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
2294        .unwrap_or_default()
2295        .into_iter()
2296        .filter(|(_, f)| f.0 == community.root_epoch.0)
2297        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2298        .collect();
2299    let control = control::ControlPlane::of(community);
2300
2301    let mut editions: Vec<ParsedEdition> = Vec::new();
2302    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2303    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2304    let mut oldest: Option<u64> = None;
2305    let mut until: Option<u64> = None;
2306    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
2307    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
2308    // error returns the stored bans (fail-safe), never an empty banlist that would
2309    // silently un-ban on withheld data.
2310    let mut a = fold_authority(community, &[], &floors);
2311    for _ in 0..FOLLOW_MAX_PAGES {
2312        // Quorum, DECLARED (the until→Full transport floor is gone): these
2313        // control reads tolerate a partial union — their fold semantics are
2314        // fail-safe on gaps (seeded banlists, withheld roster cache).
2315        let query = Query {
2316            kinds: vec![stream::KIND_WRAP],
2317            authors: vec![control.pk_hex()],
2318            until,
2319            limit: Some(FOLLOW_PAGE),
2320            evidence: crate::community::transport::Evidence::Quorum,
2321            ..Default::default()
2322        };
2323        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
2324        let mut fresh = 0usize;
2325        for w in &wraps {
2326            if !seen_wraps.insert(w.id) {
2327                continue;
2328            }
2329            fresh += 1;
2330            let at = w.created_at.as_secs();
2331            if oldest.is_none_or(|o| at < o) {
2332                oldest = Some(at);
2333            }
2334            if let Ok((ed, _)) = control.open(w) {
2335                if seen.insert(ed.inner_id) {
2336                    editions.push(ed);
2337                }
2338            }
2339        }
2340        a = fold_authority(community, &editions, &floors);
2341        if !a.gapped || fresh == 0 {
2342            break;
2343        }
2344        until = oldest;
2345    }
2346    AuthorityView {
2347        roles: a.roles,
2348        banned: a.banned,
2349        gapped: a.gapped,
2350        floored: floors.keys().cloned().collect(),
2351        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
2352        banned_at: a.banned_at,
2353    }
2354}
2355
2356/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
2357/// falls below `since_secs` (everything older is already held) or the plane is
2358/// exhausted. Returns the parsed events at/after the window plus the newest wrap
2359/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
2360///
2361/// PAGE bound rationale: a single 500-window silently drops a member whose Join
2362/// aged out (organic growth, or an insider flooding throwaway Joins), and
2363/// `refound_community` consumes the fold as its rekey recipient set — a dropped
2364/// member is SEVERED. Beyond this depth a community needs sharding (documented);
2365/// the granted-member union in [`fold_members`] is the consensus-complete
2366/// backstop regardless of Guestbook depth.
2367async fn fetch_guestbook_events<T: Transport + ?Sized>(
2368    transport: &T,
2369    community: &CommunityV2,
2370    since_secs: u64,
2371) -> Result<(Vec<guestbook::GuestbookEvent>, u64, bool), String> {
2372    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
2373    const GB_PAGE: usize = 500;
2374    const GB_MAX_PAGES: usize = 12;
2375    let mut events = Vec::new();
2376    let mut newest: u64 = since_secs;
2377    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2378    let mut until: Option<u64> = None;
2379    let mut oldest: Option<u64> = None;
2380    // Did the walk run out of PLANE, or out of PAGES? Only the former means the
2381    // caller holds everything down to `since_secs` — advancing a cursor on the
2382    // latter skips whatever the walk never reached, permanently.
2383    let mut reached_end = false;
2384    for _ in 0..GB_MAX_PAGES {
2385        // Full: this set becomes the refound's recipient list — a member's
2386        // Join visible only on a minority relay must not be severed.
2387        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
2388        let wraps = transport.fetch(&query, &community.relays).await?;
2389        let mut fresh = 0usize;
2390        for wrap in &wraps {
2391            if !seen.insert(wrap.id) {
2392                continue;
2393            }
2394            fresh += 1;
2395            let at = wrap.created_at.as_secs();
2396            if oldest.is_none_or(|o| at < o) {
2397                oldest = Some(at);
2398            }
2399            if at > newest {
2400                newest = at;
2401            }
2402            // Older than the cursor window — already held; skip the decrypt.
2403            if at < since_secs {
2404                continue;
2405            }
2406            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
2407                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
2408                    events.push(ev);
2409                }
2410            }
2411        }
2412        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
2413            reached_end = true;
2414            break;
2415        }
2416        match oldest {
2417            Some(o) if o > 0 => until = Some(o),
2418            // Nothing older to ask for.
2419            _ => {
2420                reached_end = true;
2421                break;
2422            }
2423        }
2424    }
2425    Ok((events, newest, reached_end))
2426}
2427
2428/// The shared membership fold: coalesce Guestbook events under the community's
2429/// authority (owner-supreme kicks, refounder snapshots), union observed authors
2430/// plus every roster grantee, subtract the banlist, and pin the proven owner.
2431/// One implementation, so the live and stored reads can't drift.
2432/// Whose Guestbook snapshot seeds this community's CURRENT epoch (CORD-02 §5):
2433/// "honored only from the npub whose Refounding minted that epoch". That is the
2434/// owner only when the owner did the refounding.
2435///
2436/// Vector read it as owner-always, so an admin's — or a moderation bot's —
2437/// refounding rolled the epoch and vended the keys while its survivor list was
2438/// discarded. Every client then folded its own accumulated Joins instead: members
2439/// the containment had cut lingered on the roster, and no two clients agreed on it.
2440///
2441/// The minter comes from the rotation this device VERIFIED and adopted, never from
2442/// the snapshot, so writing a snapshot grants nobody authority over it. An epoch
2443/// adopted before minters were recorded has no entry and falls back to the owner —
2444/// the previous behaviour, never a wider one. Genesis (epoch 0) has no refounder and
2445/// therefore no snapshot power at all.
2446fn snapshot_authority_for(cid_hex: &str, community: &CommunityV2, owner: &PublicKey) -> Option<PublicKey> {
2447    if community.root_epoch.0 == 0 {
2448        return None;
2449    }
2450    let minted_by = epoch_minters(cid_hex)
2451        .get(&community.root_epoch.0)
2452        .and_then(|h| PublicKey::from_hex(h).ok());
2453    Some(minted_by.unwrap_or(*owner))
2454}
2455
2456fn fold_members(
2457    community: &CommunityV2,
2458    events: &[guestbook::GuestbookEvent],
2459    mut observed: std::collections::BTreeMap<PublicKey, u64>,
2460    roles: &crate::community::roles::CommunityRoles,
2461    banlist: &std::collections::BTreeSet<PublicKey>,
2462    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
2463) -> Result<Vec<PublicKey>, String> {
2464    let owner = community.owner()?;
2465    let owner_hex = owner.to_hex();
2466    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2467
2468    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
2469    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
2470    // even if their Join aged out of the Guestbook entirely and they never posted.
2471    // This is what keeps a Refounding from severing a lurking admin. `observed`
2472    // carries them at ts 0 (presence, not recency); the banlist subtraction below
2473    // still removes a banned grantee whose grant wasn't yet stripped.
2474    for g in &roles.grants {
2475        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
2476            observed.entry(pk).or_insert(0);
2477        }
2478    }
2479
2480    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
2481    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
2482    // honored here, so a silent survivor stays in the memberlist across an owner refound
2483    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
2484    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
2485    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
2486    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
2487    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
2488    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2489    let snapshot_authority = snapshot_authority_pk.as_ref();
2490    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
2491    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
2492    // kick equal).
2493    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2494        let actor_hex = actor.to_hex();
2495        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2496            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2497    };
2498    let now = now_ms();
2499    let coalesced = guestbook::coalesce(events, now, snapshot_authority, &can_kick);
2500    // The live refound's guest list also gates the observed-forward path in
2501    // complete_memberlist, so a raid spammer whose Kick was buried by the rotation
2502    // isn't silently re-admitted by their old spam.
2503    let refound = guestbook::live_refound(events, snapshot_authority, now);
2504    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at, refound.as_ref());
2505    // The owner is a member by definition, independent of any fetched Join.
2506    if !banlist.contains(&owner) {
2507        members.insert(owner);
2508    }
2509    Ok(members.into_iter().collect())
2510}
2511
2512
2513/// What a [`rescue_stranded_members`] pass published.
2514#[derive(Debug, Default, serde::Serialize)]
2515pub struct RescueReport {
2516    /// Epoch hops that received catch-up blobs (1..=current, minus any with a missing root).
2517    pub hops: usize,
2518    /// Hops skipped because an archived root was missing — those members stay wedged there.
2519    pub hops_skipped: usize,
2520    pub chunks_published: usize,
2521    pub targets: usize,
2522}
2523
2524/// Re-vend the epoch chain to members a past rotation forgot.
2525///
2526/// DELIBERATELY not surfaced in any client: [`refound_community`]'s recipient union
2527/// exists so this situation cannot arise from our own rotations. This stays as the
2528/// operator/SDK primitive for strandings we don't control — another client's rotation
2529/// folding a narrower memberlist, or a pre-union build. It re-keyed 39 live members
2530/// once; prevention is the product, this is the fire axe.
2531///
2532/// A client follows rekeys hop by hop: from its held root it derives ONE plane —
2533/// `base_rekey_group(held_root, cid, held_epoch + 1)` — and can never jump. A member
2534/// excluded from the recipient set at hop N therefore wedges at N−1 forever; no later
2535/// rotation can reach them because they cannot even derive where to look.
2536///
2537/// The rotator holds every archived root, so it can publish what should have existed:
2538/// for each hop, additional blobs addressed to the stranded set, built with the SAME
2539/// (rotator, scope, epoch, prev-commitment) as the original rotation. `collect_rotations`
2540/// correlates on exactly that key, so the catch-up blobs merge into the rotation every
2541/// client already validated — the stranded client's next follow simply finds its blob
2542/// and walks the whole chain forward. No action needed on their side; works on any
2543/// client version.
2544///
2545/// Intermediate blobs carry the CURRENT control pair (the historical pairs are not
2546/// archived). A mid-walk client briefly holds a future control address, and the final
2547/// hop — which every walk ends on — sets the same, correct pair.
2548///
2549/// A Guestbook snapshot addendum at the current epoch re-seeds the rescued members for
2550/// outside readers (the invite page) and for clients whose fold lost them.
2551pub async fn rescue_stranded_members<T: Transport + ?Sized>(
2552    transport: &T,
2553    community: &CommunityV2,
2554    targets: &[PublicKey],
2555) -> Result<RescueReport, String> {
2556    crate::db::scoped(async move {
2557        let mut report = RescueReport { targets: targets.len(), ..Default::default() };
2558        if targets.is_empty() {
2559            return Ok(report);
2560        }
2561        let signer = crate::signer::active_signer()?;
2562        let my_pk = me_pk()?;
2563        let cid = community.id();
2564        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2565        let owner = community.owner()?;
2566        let owner_hex = owner.to_hex();
2567        // Key-granting is rotation-shaped authority: the receive side only honors blobs
2568        // inside rotations whose rotator it admits, so an unauthorized caller could only
2569        // publish noise — but fail loudly here rather than publish it.
2570        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2571        if my_pk != owner && !roster.is_authorized(&my_pk.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN) {
2572            return Err("rescuing stranded members requires rotation authority (owner or BAN)".to_string());
2573        }
2574        let control_pk = community.control_pk.ok_or("rescue requires a split community (no control pk held)")?;
2575        let citation = my_authority_citation(community, &my_pk);
2576
2577        // Every archived root, plus the live head (belt-and-suspenders: the head is
2578        // archived at rotation time, but the union costs nothing).
2579        let mut roots: std::collections::BTreeMap<u64, [u8; 32]> =
2580            crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)?
2581                .into_iter()
2582                .map(|(e, k)| (e.0, k))
2583                .collect();
2584        roots.insert(community.root_epoch.0, community.community_root);
2585
2586        let at_secs = now_ms() / 1000;
2587        for e in 1..=community.root_epoch.0 {
2588            let (Some(prev_root), Some(new_root)) = (roots.get(&(e - 1)).copied(), roots.get(&e).copied()) else {
2589                crate::log_warn!("[Rescue] no archived root for hop {} -> {} — members wedged there stay wedged", e - 1, e);
2590                report.hops_skipped += 1;
2591                continue;
2592            };
2593            let group = super::derive::base_rekey_group_key(&prev_root, cid, Epoch(e));
2594            let prev_commit = super::derive::epoch_key_commitment(Epoch(e - 1), &prev_root);
2595            // The catch-up EXTENDS the original rotation's chunk set: same correlation
2596            // key, indices past its highest slot. Re-claiming a slot is not an option —
2597            // collect_rotations keeps the first chunk per index, and if ours won, the
2598            // original recipients in the losing chunk would fold as removed.
2599            let existing = fetch_rekey_chunks(transport, &community.relays, &group).await?;
2600            let start_after = existing
2601                .iter()
2602                .filter(|c| {
2603                    c.rotator == my_pk
2604                        && c.scope == super::rekey::RekeyScope::Root
2605                        && c.new_epoch == Epoch(e)
2606                        && c.prev_commit == prev_commit
2607                })
2608                .map(|c| c.chunk.0.max(c.chunk.1))
2609                .max()
2610                .unwrap_or(0);
2611            // The owner rides every catch-up chunk: an extension inherits the original
2612            // chunks' owner blob, but a plane a relay withheld folds ours standalone,
2613            // and a rotation without an owner blob is inadmissible by design.
2614            let mut recipients: Vec<PublicKey> = targets.to_vec();
2615            if !recipients.contains(&my_pk) {
2616                recipients.push(my_pk);
2617            }
2618            let mut blobs = Vec::with_capacity(recipients.len());
2619            for r in &recipients {
2620                // Staff get the control secret exactly as a live rotation would give it.
2621                let staff = *r == my_pk || roster.is_staff(&r.to_hex(), Some(&owner_hex));
2622                let ctl_root = if staff { community.control_root.as_ref() } else { None };
2623                blobs.push(
2624                    super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, Epoch(e), &new_root, &control_pk.to_bytes(), ctl_root)
2625                        .await
2626                        .map_err(|e| e.to_string())?,
2627                );
2628            }
2629            // A rescue extension can fold STANDALONE on a withheld plane, so it
2630            // must re-state the hop's sever itself: from the fetched siblings, or
2631            // from the persisted mark when the plane withheld even those.
2632            let severed_hop = existing
2633                .iter()
2634                .any(|c| c.scope == super::rekey::RekeyScope::Root && c.new_epoch == Epoch(e) && c.severed)
2635                || sever_marks(&cid_hex).contains_key(&e);
2636            let chunks = super::rekey::build_rekey_chunk_extension(
2637                &signer,
2638                my_pk,
2639                &group,
2640                super::rekey::RekeyScope::Root,
2641                Epoch(e),
2642                Epoch(e - 1),
2643                &prev_commit,
2644                &blobs,
2645                at_secs,
2646                citation.as_ref(),
2647                severed_hop,
2648                start_after,
2649            )
2650            .await
2651            .map_err(|e| e.to_string())?;
2652            for c in &chunks {
2653                transport.publish_durable(c, &community.relays).await?;
2654                report.chunks_published += 1;
2655            }
2656            report.hops += 1;
2657        }
2658
2659        // Re-seed them in the current epoch's Guestbook: snapshot chunks are
2660        // independently useful (CORD-02 §5), so an addendum under a fresh snap id folds
2661        // as Joined on every client and every outside reader. Best-effort, like a
2662        // rotation's own snapshot.
2663        let gb_group = super::derive::guestbook_group_key(&community.community_root, cid, community.root_epoch);
2664        let snap_id = crate::community::random_32();
2665        for rumor in guestbook::build_snapshot_rumors(my_pk, targets, snap_id, at_secs * 1000) {
2666            if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2667                let _ = transport.publish(&wrap, &community.relays).await;
2668            }
2669        }
2670
2671        crate::log_info!(
2672            "[Rescue] {} member(s): {} hop(s) re-vended ({} skipped), {} chunk(s) published, snapshot addendum at epoch {}",
2673            targets.len(),
2674            report.hops,
2675            report.hops_skipped,
2676            report.chunks_published,
2677            community.root_epoch.0
2678        );
2679        Ok(report)
2680    })
2681    .await
2682}
2683
2684/// Diagnostic: the memberlist as the WIRE alone tells it — the current epoch's
2685/// guestbook plane fetched fresh and folded with no local observation and no roster
2686/// backstop. This is what an outside reader (the invite page) computes, so the diff
2687/// against [`stored_memberlist`] is exactly the set a rotation left behind: counted
2688/// locally, absent from the snapshot, holding no current key.
2689pub async fn wire_guestbook_members<T: Transport + ?Sized>(
2690    transport: &T,
2691    community: &CommunityV2,
2692) -> Result<Vec<PublicKey>, String> {
2693    let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2694    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2695    let owner = community.owner()?;
2696    let owner_hex = owner.to_hex();
2697    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2698    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2699    let snapshot_authority = snapshot_authority_pk.as_ref();
2700    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2701        let actor_hex = actor.to_hex();
2702        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2703            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2704    };
2705    let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
2706    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2707        .unwrap_or_default()
2708        .iter()
2709        .filter_map(|h| PublicKey::from_hex(h).ok())
2710        .collect();
2711    Ok(coalesced
2712        .iter()
2713        .filter(|(pk, st)| st.verdict == guestbook::Verdict::Joined && !banlist.contains(pk))
2714        .map(|(pk, _)| *pk)
2715        .collect())
2716}
2717
2718/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
2719///
2720/// This is the only sound basis for acting on a kick against ourselves. The
2721/// memberlist is the wrong question: it also folds the banlist, the ban marks and
2722/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
2723/// where the store starts empty while the control fold has already re-derived their
2724/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
2725/// only "what is the latest authorized entry for this npub", so a fresh Join
2726/// supersedes an old Kick and an empty store yields no verdict at all.
2727pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2728    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2729    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2730        return false;
2731    };
2732    let Ok(owner) = community.owner() else { return false };
2733    let owner_hex = owner.to_hex();
2734    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2735    let snapshot_authority_pk = snapshot_authority_for(&cid_hex, community, &owner);
2736    let snapshot_authority = snapshot_authority_pk.as_ref();
2737    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2738        let actor_hex = actor.to_hex();
2739        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2740            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2741    };
2742    matches!(
2743        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2744        Some(st) if st.verdict == guestbook::Verdict::Kicked
2745    )
2746}
2747
2748/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
2749/// from zero). The fetch straddles the network, so the session re-checks before
2750/// the store writes. Returns the events that were NEW to the store — the caller
2751/// surfaces them (presence lines) and refreshes on non-empty.
2752pub async fn sync_guestbook<T: Transport + ?Sized>(
2753    transport: &T,
2754    community: &CommunityV2,
2755) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2756    crate::db::scoped(async move {
2757        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2758        let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2759        // First sync of the session walks the WHOLE plane; later ones ride the cursor.
2760        // The cursor alone can only ever move forward, so any history a walk missed —
2761        // a page cap, an open breaker, stream-auth not yet landed, one relay of several
2762        // answering — is skipped for good, and two devices settle on different member
2763        // counts that never converge. Re-walking each session heals that, and it also
2764        // covers what no persisted flag can: a walk ends on a short page when the relays
2765        // that ANSWERED had no more, which is not the same as the plane being exhausted.
2766        let full = !guestbook_walked_this_session(&cid_hex);
2767        // Overlap one second so a same-second boundary event can't slip the cursor;
2768        // the rumor-id merge below dedups the re-fetched edge.
2769        let since = if full { 0 } else { cursor.saturating_sub(1) };
2770        let (fresh, newest, reached_end) = fetch_guestbook_events(transport, community, since).await?;
2771        // Every event off this fetch came from the plane we hold the key to. The stamp is
2772        // what lets the fold tell a Join carried over from a buried epoch apart from a
2773        // live one; a row stored before stamping existed earns its stamp on re-read.
2774        let live = Some(community.root_epoch.0);
2775        let mut by_id: std::collections::HashMap<[u8; 32], usize> = events.iter().enumerate().map(|(i, e)| (e.rumor_id, i)).collect();
2776        let mut added = Vec::new();
2777        let mut restamped = false;
2778        for mut ev in fresh {
2779            ev.epoch = live;
2780            match by_id.get(&ev.rumor_id) {
2781                Some(&i) => {
2782                    if events[i].epoch.is_none() {
2783                        events[i].epoch = live;
2784                        restamped = true;
2785                    }
2786                }
2787                None => {
2788                    // First sight on THIS client. The author writes `at_ms`; they do
2789                    // not write this.
2790                    let mut ev = ev;
2791                    if ev.observed_at.is_none() {
2792                        ev.observed_at = Some(
2793                            web_time::SystemTime::now()
2794                                .duration_since(web_time::UNIX_EPOCH)
2795                                .map(|d| d.as_secs())
2796                                .unwrap_or(0),
2797                        );
2798                    }
2799                    by_id.insert(ev.rumor_id, events.len());
2800                    events.push(ev.clone());
2801                    added.push(ev);
2802                }
2803            }
2804        }
2805        // Advance ONLY on a walk that ran out of plane. `newest` counts every wrap seen,
2806        // including ones skipped or that failed to open, so moving it after a truncated
2807        // walk is what buries the events that walk never reached.
2808        let advanced = if reached_end { newest.max(cursor) } else { cursor };
2809        if !added.is_empty() || advanced > cursor || restamped {
2810            crate::db::community::set_guestbook(&cid_hex, &events, advanced)?;
2811            mark_guestbook_observed_since(&cid_hex);
2812        }
2813        if full && reached_end {
2814            mark_guestbook_walked(&cid_hex);
2815        }
2816        Ok(added)
2817    })
2818    .await
2819}
2820
2821/// Communities whose Guestbook plane has been walked end-to-end this session.
2822/// Lives on the Session, so an account swap drops it and the next account walks
2823/// its own rather than inheriting this one's coverage.
2824struct GuestbookWalked;
2825
2826fn guestbook_walked_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>> {
2827    crate::db::current_session().scoped::<GuestbookWalked, _>()
2828}
2829
2830fn guestbook_walked_this_session(community_id: &str) -> bool {
2831    guestbook_walked_set().lock().is_ok_and(|s| s.contains(community_id))
2832}
2833
2834fn mark_guestbook_walked(community_id: &str) {
2835    if let Ok(mut s) = guestbook_walked_set().lock() {
2836        s.insert(community_id.to_string());
2837    }
2838}
2839
2840/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
2841/// Returns whether it was new.
2842pub fn ingest_guestbook_event(community: &CommunityV2, mut ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2843    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2844    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2845    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2846        return Ok(false);
2847    }
2848    ev.epoch = Some(community.root_epoch.0);
2849    events.push(ev);
2850    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2851    Ok(true)
2852}
2853
2854/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
2855/// observed authors (the synced events DB), plus roster grantees, minus the
2856/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
2857/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
2858/// remains the authoritative walk — a refounding's rekey recipient set must
2859/// never trust a possibly-stale store.
2860/// Unix seconds from which this client has been following `cid_hex`'s guestbook.
2861///
2862/// Set once, on the first sync that stores anything, and never moved forward. It is
2863/// what makes `observed_at` usable as evidence: a client that only started watching
2864/// AFTER a raid began sees every member as new, so its observations prove nothing and
2865/// must not be allowed to evict anyone.
2866fn guestbook_observed_since(cid_hex: &str) -> Option<u64> {
2867    crate::db::settings::get_sql_setting(format!("gb_observed_since:{cid_hex}"))
2868        .ok()
2869        .flatten()
2870        .and_then(|v| v.parse::<u64>().ok())
2871}
2872
2873fn mark_guestbook_observed_since(cid_hex: &str) {
2874    let key = format!("gb_observed_since:{cid_hex}");
2875    if crate::db::settings::get_sql_setting(key.clone()).ok().flatten().is_some() {
2876        return; // never moves forward — a later stamp would erase our own coverage
2877    }
2878    let now = web_time::SystemTime::now()
2879        .duration_since(web_time::UNIX_EPOCH)
2880        .map(|d| d.as_secs())
2881        .unwrap_or(0);
2882    let _ = crate::db::settings::set_sql_setting(key, now.to_string());
2883}
2884
2885pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2886    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2887    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2888    stored_memberlist_from(community, &events)
2889}
2890
2891/// [`stored_memberlist`] over a guestbook the caller already holds, so a read that needs
2892/// the cursor too decrypts and parses the plane once.
2893pub fn stored_memberlist_from(community: &CommunityV2, events: &[guestbook::GuestbookEvent]) -> Result<Vec<PublicKey>, String> {
2894    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2895    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2896    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2897        if let Ok(pk) = PublicKey::parse(&npub) {
2898            observed.insert(pk, last_active_secs.saturating_mul(1000));
2899        }
2900    }
2901    let roles = crate::db::community::get_community_roles(&cid_hex)?;
2902    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2903        .unwrap_or_default()
2904        .iter()
2905        .filter_map(|h| PublicKey::from_hex(h).ok())
2906        .collect();
2907    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
2908    // since this path never folds editions.
2909    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2910        .unwrap_or_default()
2911        .into_iter()
2912        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2913        .collect();
2914    fold_members(community, events, observed, &roles, &banlist, &banned_at)
2915}
2916
2917/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2918/// ALWAYS a member (derived from the self-certifying community_id — no network,
2919/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2920/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2921/// member whose Join was lost still counts.
2922pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2923    let (events, _newest, _reached_end) = fetch_guestbook_events(transport, community, 0).await?;
2924    // Observed authors: fold each held channel's recent authorship (real author +
2925    // newest ms), so a member who posted but whose Join was lost is still counted.
2926    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2927    for ch in &community.channels {
2928        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2929            for f in &page {
2930                let e = observed.entry(f.event.opened().author).or_insert(0);
2931                *e = (*e).max(f.event.opened().at_ms);
2932            }
2933        }
2934    }
2935
2936    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2937    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2938    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2939    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2940    let authority = fetch_authority(transport, community).await;
2941    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2942    let banlist: std::collections::BTreeSet<PublicKey> =
2943        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2944    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2945    // editions still in its window, and a ban that aged out is exactly the one whose
2946    // pre-ban Join would phantom.
2947    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2948        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2949    )
2950    .unwrap_or_default()
2951    .into_iter()
2952    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2953    .collect();
2954    for (h, at) in &authority.banned_at {
2955        if let Ok(pk) = PublicKey::from_hex(h) {
2956            let slot = banned_at.entry(pk).or_insert(0);
2957            *slot = (*slot).max(*at);
2958        }
2959    }
2960    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2961}
2962
2963// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2964
2965/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2966/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2967/// past or present member resolves the same grave and a Refounding can never strand
2968/// it). The tombstone's presence IS the state; only the owner's seal counts.
2969/// Irreversible — on success the local hold is sealed read-only AND the owner's
2970/// own membership is tombstoned out of the §8 List: dissolving is also leaving,
2971/// so the owner's other devices tear the community down on their next list sync
2972/// instead of keeping a sealed husk they believe is still held.
2973pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2974    crate::db::scoped(async move {
2975        let signer = crate::signer::active_signer()?;
2976        let my_pk = me_pk()?;
2977        if community.owner()? != my_pk {
2978            return Err("only the owner can dissolve a community".to_string());
2979        }
2980        let at_ms = now_ms();
2981        let at = at_ms / 1000;
2982        let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2983        let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2984        // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2985        transport.publish_durable(&wrap, &community.relays).await?;
2986        crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2987        // Dissolving IS leaving (same discipline as `leave_community`): retire the
2988        // membership from the cross-device List in the same breath as the grave.
2989        // Without this the tombstone never reaches kind 33302 — the teardown above
2990        // this call only ever spoke the retired v1 mirror — so every sibling device
2991        // and every §8 client kept the entry LIVE, folded the grave, and showed a
2992        // dissolved husk that had to be removed by hand. Best-effort with a durable
2993        // retry, never failing the dissolve: the grave is already published, and an
2994        // unrecorded leave only costs the husk lingering until the retry lands.
2995        if let Err(e) = tombstone_community_list(transport, community.id(), &community.relays, at_ms).await {
2996            crate::log_net_fail!("[CommunityList] dissolve tombstone failed ({}) — retrying in the background", e);
2997            tombstone_community_list_durable(*community.id(), community.relays.clone(), at_ms);
2998        }
2999        Ok(())
3000    })
3001    .await
3002}
3003
3004/// Whether a valid owner-signed dissolution tombstone exists for this community on
3005/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
3006/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
3007/// owner-verified tombstone found is authoritative.
3008pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
3009    let group = super::derive::dissolved_group_key(community.id());
3010    let query = Query {
3011        kinds: vec![stream::KIND_WRAP],
3012        authors: vec![group.pk_hex()],
3013        limit: Some(20),
3014        ..Default::default()
3015    };
3016    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
3017        return false;
3018    };
3019    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
3020}
3021
3022// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
3023
3024/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
3025/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
3026/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
3027/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
3028/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
3029/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
3030///
3031/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
3032/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
3033/// transient miss never strands a published rekey with a half-anchored plane.
3034pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
3035    refound_community_with(transport, community, removed, false, 0, None).await
3036}
3037
3038/// A SEVERING Refounding (CORD-06 Severing): the rotation carries the `sever`
3039/// marker, and every adopter — this client included — revokes its own public
3040/// invite links instead of refreshing them into the new epoch. The read-cut is
3041/// real even while the community was Public: the door closes with the rotation,
3042/// and only a deliberate fresh mint re-opens it.
3043pub async fn refound_community_severing<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], joined_after_cut: u64) -> Result<CommunityV2, String> {
3044    refound_community_with(transport, community, removed, true, joined_after_cut, None).await
3045}
3046
3047/// `joined_after_cut`: seconds. A member whose EARLIEST Join is at or after this is
3048/// vended no key. 0 disables it. The test is positive evidence only — a member with no
3049/// Join on the reachable guestbook (buried epoch, aged-out window) is NOT excluded, and
3050/// an unreadable guestbook excludes nobody. Absence must never evict: five rotations
3051/// once shed 39 real members that way.
3052/// Re-found vending keys to `retain` ONLY (plus the owner and this device).
3053///
3054/// An operator picked a list; anyone not on it gets nothing, whenever they arrived.
3055/// The removal-list form cannot express that: it is computed from the memberlist at
3056/// one instant while the refound fetches its own later, so an account that appears in
3057/// between is on neither list and is vended a key by default — implicitly RETAINED by
3058/// the very purge meant to be exhaustive. An allow-list has no such gap, and unlike a
3059/// join-time cut it is immune to a backdated (author-claimed) Join.
3060pub async fn refound_community_retaining<T: Transport + ?Sized>(
3061    transport: &T,
3062    community: &CommunityV2,
3063    removed: &[PublicKey],
3064    retain: &[PublicKey],
3065) -> Result<CommunityV2, String> {
3066    refound_community_with(transport, community, removed, false, 0, Some(retain)).await
3067}
3068
3069async fn refound_community_with<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey], sever: bool, joined_after_cut: u64, allow_only: Option<&[PublicKey]>) -> Result<CommunityV2, String> {
3070    crate::db::scoped(async move {
3071        let cid = community.id();
3072        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3073        // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
3074        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3075            return Err("this community has been dissolved; it cannot be re-founded".to_string());
3076        }
3077        let signer = crate::signer::active_signer()?;
3078        let my_pk = me_pk()?;
3079        // Serialize with the follow worker for the whole rotation: the commit tail
3080        // whole-row-saves, and an unserialized concurrent follow could otherwise be
3081        // rolled back (or adopt a half-published sibling of this very rotation).
3082        let lock = super::realtime::follow_lock(cid);
3083        let _guard = lock.lock().await;
3084        // Reload the FRESHEST base state: a stale caller struct would address the rotation
3085        // under a superseded root (a base fork with no heal). The community_id is
3086        // self-certifying + stable, so re-loading by it is safe.
3087        let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
3088        let community = &fresh;
3089        let owner = community.owner()?;
3090
3091        // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
3092        // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
3093        // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
3094        // and the banlist authority fold: any admin holding BAN may re-found, checked
3095        // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
3096        // only the owner able to re-found.
3097        {
3098            let owner_hex = owner.to_hex();
3099            let me_hex = my_pk.to_hex();
3100            // Persisted (last-folded) roster — the receive side is authoritative, so
3101            // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
3102            // collapses to owner-only, which can only OVER-restrict a fresh admin whose
3103            // grant hasn't folded into their own DB (the caller's ban flow folds control
3104            // first). It can never grant authority no one has.
3105            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3106            let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3107            let authorized = my_pk == owner
3108                || (!banned.contains(&me_hex)
3109                    && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3110                    && removed.iter().all(|t| {
3111                        roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
3112                    }));
3113            if !authorized {
3114                return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
3115            }
3116        }
3117
3118        // Fold the current roster: the opened editions are reused for the compaction (their
3119        // seals re-wrap under the new epoch), and the roster gates which admin-authored
3120        // heads carry forward.
3121        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3122            .into_iter()
3123            .filter(|(_, f)| f.0 == community.root_epoch.0)
3124            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3125            .collect();
3126        let current_control = control::ControlPlane::of(community);
3127        // Page the ENTIRE control plane, not just the newest window: the compaction MUST
3128        // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
3129        // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
3130        // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
3131        // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
3132        // at the new epoch a fresh joiner bootstraps.
3133        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3134        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3135        let mut oldest: Option<u64> = None;
3136        let mut until: Option<u64> = None;
3137        // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
3138        // first-ever Banlist published while we were away) is invisible to a
3139        // coverage test, so stopping there could compact it away.
3140        let mut truncated = false;
3141        for page in 0..COMPACT_MAX_PAGES {
3142            // Full: compaction re-wraps the head set it can SEE — a control
3143            // edition (a ban head) reachable only on a minority relay must not be
3144            // compacted away by a partial union.
3145            let query = Query {
3146                kinds: vec![stream::KIND_WRAP],
3147                authors: vec![current_control.pk_hex()],
3148                until,
3149                limit: Some(FOLLOW_PAGE),
3150                evidence: crate::community::transport::Evidence::Full,
3151                ..Default::default()
3152            };
3153            let wraps = transport.fetch(&query, &community.relays).await?;
3154            let mut fresh = 0usize;
3155            for w in &wraps {
3156                if !seen_wraps.insert(w.id) {
3157                    continue;
3158                }
3159                fresh += 1;
3160                let at = w.created_at.as_secs();
3161                if oldest.is_none_or(|o| at < o) {
3162                    oldest = Some(at);
3163                }
3164                if let Ok(parsed) = current_control.open(w) {
3165                    opened.push(parsed);
3166                }
3167            }
3168            if fresh == 0 {
3169                // `until` is inclusive: a FULL page with nothing new is a same-second
3170                // wall no cursor steps past, so older editions stay unreachable. A
3171                // short page is simply the end of the plane.
3172                truncated = wraps.len() >= FOLLOW_PAGE;
3173                break;
3174            }
3175            until = oldest;
3176            if page + 1 == COMPACT_MAX_PAGES {
3177                truncated = true;
3178            }
3179        }
3180        if truncated {
3181            return Err(
3182                "The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
3183            );
3184        }
3185
3186        let prev_epoch = community.root_epoch;
3187        let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
3188        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3189        // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
3190        // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
3191        // it can't double-mint two roots a receiver's correlation dedup would collapse into
3192        // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
3193        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3194        // Every compliant base rotation mints the split beside the root (CORD-06
3195        // §3) — a legacy community upgrades as a side effect of this Refounding.
3196        // Reserved under the same retry-idempotency: both attempts must deliver ONE
3197        // pair, or staff adopt whichever secret rode the chunk with their locator.
3198        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3199        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3200        // The new epoch's Control plane is SPLIT: wraps sign with the fresh
3201        // control_root-derived signer and encrypt under the new community_root-
3202        // derived read key. Editions are never ALSO mirrored to the legacy-derived
3203        // address — that would re-open exactly the member-writable surface the
3204        // split closes (CORD-06 §3).
3205        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3206        let at = now_ms();
3207        let at_secs = at / 1000;
3208
3209        // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
3210        // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
3211        // including entities the metadata/roster folds don't touch (the invite Registry
3212        // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
3213        // can't be folded (buried past the pager / withheld) ABORTS before any publish.
3214        use std::collections::BTreeMap;
3215        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3216        for (i, (e, _)) in opened.iter().enumerate() {
3217            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3218        }
3219        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3220        for (floor_key, floor) in &floors {
3221            // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
3222            // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
3223            // so matching it is authority-correct across EVERY entity type. `fold_head`'s
3224            // version-chain TIP is author-BLIND: a member can seal a forged higher-version
3225            // edition chaining onto the floor, which the tip would carry and honest folders
3226            // then DROP as unauthorized — silently suppressing that role/grant/banlist across
3227            // the refounding. Abort if the committed head isn't served (fail-closed).
3228            let head_idx = by_eid
3229                .get(floor_key)
3230                .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3231            let Some(head_idx) = head_idx else {
3232                return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
3233            };
3234            let (head_ed, head_os) = &opened[head_idx];
3235            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3236            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3237            carried.push((h, rewrapped));
3238        }
3239
3240        // Recipients: the current members minus `removed`, plus me (multi-device).
3241        //
3242        // UNION of the network fold and the LOCAL memberlist. The network fold only
3243        // observes the recent window, so a quiet member whose Join lives on a buried
3244        // epoch falls out of it — and out of the key set and snapshot — on every
3245        // rotation. Five rotations in one evening shed 39 real members that way, each
3246        // left wedged at the hop that first forgot them. Locally-known members are
3247        // vended keys too; being forgotten is not a removal, only `removed` is.
3248        let members = memberlist(transport, community).await?;
3249        let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
3250        let net_len = members.len();
3251        let mut union: std::collections::BTreeSet<PublicKey> = members.into_iter().collect();
3252        let local = stored_memberlist(community)?;
3253        let local_len = local.len();
3254        union.extend(local);
3255        let mut recipients: Vec<PublicKey> = union.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
3256        if !recipients.contains(&my_pk) {
3257            recipients.push(my_pk);
3258        }
3259
3260        // The freshest roster reachable, for two per-recipient decisions below:
3261        // channel entitlement AND staff-ness (who receives the 136-byte blob).
3262        // Entitlement must come from a CURRENT roster, not the last-folded cache:
3263        // the base recipients above are a fresh network fold, and mixing the two
3264        // strands anyone granted since this client last folded — they keep a dead
3265        // key and the new epoch's rekey plane carries no blob for them. Fetched,
3266        // then merged over the cache so a role we published ourselves survives too.
3267        let mut roster_for_channels = fetch_authority(transport, community).await.roles;
3268        {
3269            let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3270            for r in cached.roles {
3271                if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
3272                    roster_for_channels.roles.push(r);
3273                }
3274            }
3275            for g in cached.grants {
3276                if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
3277                    roster_for_channels.grants.push(g);
3278                }
3279            }
3280        }
3281        let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
3282
3283        // Operator purge: vend ONLY to the chosen list.
3284        //
3285        // Not a time test — a hard allow-list, so an account that arrived between the
3286        // operator's selection and this fetch is excluded by NOT BEING ON IT, with no
3287        // window to slip through and nothing to backdate. Owner and this device are
3288        // always kept: a purge must not orphan the community or lock out the operator
3289        // running it.
3290        if let Some(allow) = allow_only {
3291            let allowed: std::collections::HashSet<[u8; 32]> = allow.iter().map(|p| p.to_bytes()).collect();
3292            let before = recipients.len();
3293            recipients.retain(|r| {
3294                *r == my_pk
3295                    || Some(r.to_hex()) == owner_hex_for_channels
3296                    || allowed.contains(&r.to_bytes())
3297            });
3298            let dropped = before.saturating_sub(recipients.len());
3299            if dropped > 0 {
3300                crate::log_info!(
3301                    "[Refound {}] purge: {} member(s) not on the retain list vended no key",
3302                    &cid_hex[..8.min(cid_hex.len())], dropped
3303                );
3304            }
3305        }
3306
3307        // Containment: vend no key to anyone who walked in DURING the raid.
3308        //
3309        // `removed` is a list computed at one instant; the recipient set above is a
3310        // LIVE fetch taken later, and re-taken on every retry. A bot joining in that
3311        // gap lands in the memberlist, misses the list, and is handed a working key
3312        // for the epoch meant to evict it. Testing the join TIME instead of matching a
3313        // frozen list is immune to when the fetch happens: a later read only surfaces
3314        // more late joiners, and each one fails the same test.
3315        //
3316        // Positive evidence only. No Join on the reachable guestbook means OLD (or
3317        // buried), never "new" — and an unreadable guestbook filters nobody, because
3318        // failing closed here would evict the whole community on one bad read.
3319        if joined_after_cut > 0 {
3320            match fetch_guestbook_events(transport, community, 0).await {
3321                Ok((events, _, _)) => {
3322                    let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
3323                    for ev in &events {
3324                        if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
3325                            let secs = at_ms / 1000;
3326                            first_seen.entry(*member).and_modify(|e| *e = (*e).min(secs)).or_insert(secs);
3327                        }
3328                    }
3329                    // A Join's `at_ms` is written by its author, so a raider can
3330                    // backdate under any window computed from it. What this client
3331                    // WATCHED arrive, they cannot forge — but only if we were already
3332                    // following before the window opened. A client that started
3333                    // watching later sees everyone as new, and trusting it would evict
3334                    // the whole community.
3335                    if guestbook_observed_since(&cid_hex).is_some_and(|since| since < joined_after_cut) {
3336                        if let Ok((local, _)) = crate::db::community::get_guestbook(&cid_hex) {
3337                            for ev in &local {
3338                                let (guestbook::GuestbookEntry::Join { member, .. }, Some(seen)) = (&ev.entry, ev.observed_at) else {
3339                                    continue;
3340                                };
3341                                // Incriminating only: an observation can move a member
3342                                // LATER (catching a backdated claim), never earlier.
3343                                first_seen.entry(*member).and_modify(|e| *e = (*e).max(seen)).or_insert(seen);
3344                            }
3345                        }
3346                    }
3347                    let before = recipients.len();
3348                    recipients.retain(|r| {
3349                        if *r == my_pk || Some(r.to_hex()) == owner_hex_for_channels {
3350                            return true;
3351                        }
3352                        // A moderator who joined moments before a raid is still a moderator.
3353                        if roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref()) {
3354                            return true;
3355                        }
3356                        first_seen.get(r).is_none_or(|joined| *joined < joined_after_cut)
3357                    });
3358                    let dropped = before.saturating_sub(recipients.len());
3359                    if dropped > 0 {
3360                        crate::log_info!(
3361                            "[Refound {}] containment: {} raid-window joiner(s) vended no key",
3362                            &cid_hex[..8.min(cid_hex.len())], dropped
3363                        );
3364                    }
3365                }
3366                Err(e) => crate::log_warn!(
3367                    "[Refound {}] guestbook unreadable — raid-window joiners NOT filtered, only the named cut is severed ({e})",
3368                    &cid_hex[..8.min(cid_hex.len())]
3369                ),
3370            }
3371        }
3372
3373        // STAFF COVERAGE GATE: a granted member who is not a recipient would be
3374        // stranded at this hop for good — they can only ever derive `held + 1`, so
3375        // no later rotation reaches them, while every other client still reads them
3376        // as staff. That is silent and permanent, and it happened: an admin holding
3377        // a valid grant was vended no key and only surfaced because a human noticed
3378        // the member list had shrunk.
3379        //
3380        // A grant is consensus-complete proof of membership — the same claim
3381        // `fold_members` makes when it counts granted members "even if their Join
3382        // aged out of the Guestbook entirely". So the recipients must cover them.
3383        // Refusing here matches the entity carry-forward directly above, which
3384        // ABORTS rather than publish a rotation that drops a floored head: better a
3385        // rotation that did not happen than one that quietly severs the staff.
3386        //
3387        // Only the CUT are exempt — removing someone is the one thing a rotation is
3388        // allowed to do to them.
3389        {
3390            // A banned member is not owed a key; every other grant-holder is.
3391            let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3392            let missing: Vec<String> = roster_for_channels
3393                .grants
3394                .iter()
3395                .filter(|g| !g.role_ids.is_empty())
3396                .filter_map(|g| PublicKey::from_hex(&g.member).ok())
3397                .filter(|pk| !removed_set.contains(&pk.to_bytes()))
3398                .filter(|pk| !banned.contains(&pk.to_hex()))
3399                .filter(|pk| !recipients.contains(pk))
3400                .map(|pk| pk.to_hex())
3401                .collect();
3402            if !missing.is_empty() {
3403                // The counts ride the message: which of the two folds lost them is
3404                // the whole diagnosis, and a refusal that does not say leaves the
3405                // next person guessing exactly as long as this one did.
3406                return Err(format!(
3407                    "re-founding aborted: {} granted member(s) would be vended no key and stranded at epoch {} — {}; \
3408                     recipients={} (network fold {}, local fold {}); no state published",
3409                    missing.len(),
3410                    prev_epoch.0,
3411                    missing.iter().map(|h| h[..8.min(h.len())].to_string()).collect::<Vec<_>>().join(", "),
3412                    recipients.len(),
3413                    net_len,
3414                    local_len
3415                ));
3416            }
3417        }
3418
3419        // Base rekey blobs, sealed under the PRIOR root: every member's carries the
3420        // new root + control_pk (104 bytes); a STAFF recipient's (CORD-04 §3)
3421        // appends the control_root itself (136) — the rotator is staff by
3422        // construction (a Refounding takes BAN). A stale-roster overshoot hands a
3423        // just-demoted member only flooding power until the next rotation, the
3424        // spec's accepted erosion; an undershoot self-heals via the Grant's
3425        // control_wrap re-delivery.
3426        let mut base_blobs = Vec::new();
3427        for r in &recipients {
3428            let staff = *r == my_pk || roster_for_channels.is_staff(&r.to_hex(), owner_hex_for_channels.as_deref());
3429            base_blobs.push(
3430                super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), r, new_epoch, &new_root, &new_control_pk.to_bytes(), staff.then_some(&new_control_root))
3431                    .await
3432                    .map_err(|e| e.to_string())?,
3433            );
3434        }
3435        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3436        let base_chunks =
3437            super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), sever)
3438                .await
3439                .map_err(|e| e.to_string())?;
3440
3441        // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
3442        // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
3443        //
3444        // Each private channel goes only to ITS entitled set, never the base recipient
3445        // list: a Refounding that re-broadcast every private key to every member would
3446        // undo the access lists on every rotation (CORD-03).
3447        let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
3448        let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
3449        for ch in &community.channels {
3450            let (Some(old_key), true) = (ch.key, ch.private) else { continue };
3451            let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
3452            let entitled: Vec<PublicKey> = recipients
3453                .iter()
3454                .copied()
3455                .filter(|r| {
3456                    *r == my_pk
3457                        || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
3458                })
3459                .collect();
3460            let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
3461            // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
3462            // retry-idempotency as the base root. The base-rekey signing above is a bunker
3463            // round-trip; re-check before this per-channel DB write straddles it.
3464            let ch_new_key = mint_or_reuse_rotation_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch_new_epoch.0)?;
3465            let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
3466            let mut ch_blobs = Vec::new();
3467            for r in &entitled {
3468                ch_blobs.push(
3469                    super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
3470                        .await
3471                        .map_err(|e| e.to_string())?,
3472                );
3473            }
3474            let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
3475            let ch_chunks = super::rekey::build_rekey_chunks(&signer, my_pk, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3476                .await
3477                .map_err(|e| e.to_string())?;
3478            channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
3479            channel_chunk_sets.push(ch_chunks);
3480        }
3481
3482        // COMMIT (durable publishes only — all fetching is done). Base rekey first
3483        // (delivers the new root), then channel rekeys, then the compacted control.
3484        for c in &base_chunks {
3485            transport.publish_durable(c, &community.relays).await?;
3486        }
3487        for set in &channel_chunk_sets {
3488            for c in set {
3489                transport.publish_durable(c, &community.relays).await?;
3490            }
3491        }
3492        for (_, wrap) in &carried {
3493            transport.publish_durable(wrap, &community.relays).await?;
3494        }
3495        // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
3496        // it; an omitted member heals by publishing their own Join).
3497        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3498        let snap_id = crate::community::random_32();
3499        for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
3500            if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
3501                let _ = transport.publish(&wrap, &community.relays).await;
3502            }
3503        }
3504
3505        // COMMIT locally, only now that the new root + compacted plane are on relays.
3506        if crate::db::community::community_protocol(cid)?.is_none() {
3507            return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
3508        }
3509        // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
3510        // never leave the base root advanced while the channel keys lag (which would
3511        // re-derive the channel rekey address under the wrong root and orphan them).
3512        let mut updated = community.clone();
3513        updated.community_root = new_root;
3514        updated.root_epoch = new_epoch;
3515        updated.control_pk = Some(new_control_pk);
3516        updated.control_root = Some(new_control_root);
3517        for (id, key, ep) in &channel_updates {
3518            if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
3519                c.key = Some(*key);
3520                c.epoch = *ep;
3521            }
3522        }
3523        crate::db::community::save_community_v2(&updated)?;
3524        // Archive the new epoch key + confirm the monotonic base head (the root was already
3525        // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
3526        // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
3527        // compacted control on the next follow, so they self-heal.
3528        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3529        for (h, _) in &carried {
3530            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3531        }
3532        // Re-subscribe NOW: the rotation changed every plane author, and the live sub
3533        // still carries the OLD epoch's set. Members adopt via the follow worker
3534        // (which refreshes); the REFOUNDER has no such path — without this, the very
3535        // client that performed the ban goes deaf to the new epoch (a rejoin lands on
3536        // the relays and never arrives live).
3537        if let Some(client) = crate::state::nostr_client() {
3538            super::realtime::refresh_subscription(&client).await;
3539        }
3540        // We minted this epoch, so our own snapshot is what seeds it (CORD-02 §5).
3541        // Recorded here so the local fold agrees with every follower's without
3542        // waiting to adopt our own rotation back off the wire.
3543        record_epoch_minter(&cid_hex, new_epoch.0, &my_pk.to_hex());
3544        if sever {
3545            // Severing: the mark persists FIRST (a crash between it and the revoke
3546            // must retry, not forget — B4), then my own links are tombstoned. Other
3547            // creators' links die when their clients adopt the marked rotation.
3548            add_sever_mark(&cid_hex, new_epoch.0, at_secs);
3549            for attempt in 0..3u8 {
3550                match sever_own_links(transport, &updated).await {
3551                    Ok(_) => break,
3552                    Err(e) if attempt == 2 => {
3553                        crate::log_warn!("v2: post-severing link revocation failed after retries ({e}); the sever mark retries it on the next follow");
3554                    }
3555                    Err(_) => continue,
3556                }
3557            }
3558        } else {
3559            // Refresh any live public links so their bundles carry the NEW root behind the
3560            // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
3561            // so retry a transient failure — a stranded link lands a new joiner on the dead
3562            // pre-refound epoch, and there's no other trigger to heal it before the next
3563            // refounding. A persistent failure is logged (refound already succeeded).
3564            for attempt in 0..3u8 {
3565                match refresh_public_links(transport, &updated).await {
3566                    Ok(()) => break,
3567                    Err(e) if attempt == 2 => {
3568                        crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
3569                    }
3570                    Err(_) => continue,
3571                }
3572            }
3573        }
3574        Ok(updated)
3575    })
3576    .await
3577}
3578
3579/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
3580/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
3581/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
3582/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
3583/// the two sets are DECOUPLED:
3584///
3585/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
3586///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
3587///   set at {owner} also keeps the rotation to a single chunk (blobs shard at 80 per event,
3588///   so a big memberlist would cost a stack of events delivering roots nobody uses).
3589/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
3590///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
3591///
3592/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
3593/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
3594/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
3595pub async fn refound_at_birth<T: Transport + ?Sized>(
3596    transport: &T,
3597    community: &CommunityV2,
3598    snapshot_members: &[PublicKey],
3599) -> Result<CommunityV2, String> {
3600    crate::db::scoped(async move {
3601        let cid = community.id();
3602        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3603        // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
3604        // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
3605        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3606            return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
3607        }
3608        let signer = crate::signer::active_signer()?;
3609        let my_pk = me_pk()?;
3610        if my_pk != community.owner()? {
3611            return Err("only the owner can birth-refound the migration twin".to_string());
3612        }
3613        let lock = super::realtime::follow_lock(cid);
3614        let _guard = lock.lock().await;
3615        let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
3616        // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
3617        // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
3618        // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
3619        // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
3620        // unexpected (nothing else rotates a mid-migration twin).
3621        if community.root_epoch.0 == 1 {
3622            return Ok(community);
3623        }
3624        if community.root_epoch.0 != 0 {
3625            return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
3626        }
3627        let community = &community;
3628
3629        // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
3630        // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
3631        // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
3632        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3633            .into_iter()
3634            .filter(|(_, f)| f.0 == community.root_epoch.0)
3635            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3636            .collect();
3637        let current_control = control::ControlPlane::of(community);
3638        let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
3639        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3640        let mut oldest: Option<u64> = None;
3641        let mut until: Option<u64> = None;
3642        // Exhaustion, not coverage — see the sibling read in `refound_community`.
3643        let mut truncated = false;
3644        for page in 0..COMPACT_MAX_PAGES {
3645            let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![current_control.pk_hex()], until, limit: Some(FOLLOW_PAGE), evidence: crate::community::transport::Evidence::Full, ..Default::default() };
3646            let wraps = transport.fetch(&query, &community.relays).await?;
3647            let mut fresh = 0usize;
3648            for w in &wraps {
3649                if !seen_wraps.insert(w.id) { continue; }
3650                fresh += 1;
3651                let at = w.created_at.as_secs();
3652                if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
3653                if let Ok(parsed) = current_control.open(w) {
3654                    opened.push(parsed);
3655                }
3656            }
3657            if fresh == 0 {
3658                truncated = wraps.len() >= FOLLOW_PAGE;
3659                break;
3660            }
3661            until = oldest;
3662            if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
3663        }
3664        if truncated {
3665            return Err(
3666                "The community's control plane is too deep to read in full right now; re-founding stopped so no member is left behind.".to_string(),
3667            );
3668        }
3669
3670        let prev_epoch = community.root_epoch; // 0
3671        let new_epoch = Epoch(1);
3672        let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
3673        let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
3674        // The split's pair, minted beside the root exactly as in `refound_community`.
3675        let new_control_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::CONTROL_ROOT_SCOPE, new_epoch.0)?;
3676        let new_control_pk = super::derive::control_signer_group_key(&new_control_root, cid, new_epoch).pk();
3677        let new_control = control::split_write_group(&new_control_root, &new_root, cid, new_epoch);
3678        let at = now_ms();
3679        let at_secs = at / 1000;
3680
3681        use std::collections::BTreeMap;
3682        let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3683        for (i, (e, _)) in opened.iter().enumerate() {
3684            by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
3685        }
3686        let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
3687        for (floor_key, floor) in &floors {
3688            let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
3689            let Some(head_idx) = head_idx else {
3690                return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
3691            };
3692            let (head_ed, head_os) = &opened[head_idx];
3693            let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
3694            let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
3695            carried.push((h, rewrapped));
3696        }
3697
3698        // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the
3699        // carrier's `m`). The owner is staff by definition — the 136-byte form.
3700        let base_blobs = vec![
3701            super::rekey::build_base_blob(&signer, &my_pk.to_bytes(), &my_pk, new_epoch, &new_root, &new_control_pk.to_bytes(), Some(&new_control_root))
3702                .await
3703                .map_err(|e| e.to_string())?,
3704        ];
3705        let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
3706        let base_chunks =
3707            super::rekey::build_rekey_chunks(&signer, my_pk, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
3708                .await
3709                .map_err(|e| e.to_string())?;
3710
3711        // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
3712        for c in &base_chunks {
3713            transport.publish_durable(c, &community.relays).await?;
3714        }
3715        for (_, wrap) in &carried {
3716            transport.publish_durable(wrap, &community.relays).await?;
3717        }
3718        // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
3719        // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
3720        // live members heal via their own Join if a chunk drops), a seeded-never-landed member
3721        // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
3722        // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
3723        // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
3724        // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
3725        let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
3726        let snap_id = crate::community::random_32();
3727        let snapshot_wraps: Vec<Event> = {
3728            let mut out = Vec::new();
3729            for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
3730                let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
3731                    .await
3732                    .map_err(|e| format!("seal birth snapshot: {e}"))?;
3733                out.push(wrap);
3734            }
3735            out
3736        };
3737        for wrap in &snapshot_wraps {
3738            transport.publish_durable(wrap, &community.relays).await?;
3739        }
3740        // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
3741        // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
3742        // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
3743        // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
3744        // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
3745        // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
3746        // banlist from the expected set. The real caller never seeds a banned member, but the
3747        // arbitrary-`snapshot_members` API must not be able to wedge on one.
3748        let verify_view = {
3749            let mut v = community.clone();
3750            v.community_root = new_root;
3751            v.root_epoch = new_epoch;
3752            v.control_pk = Some(new_control_pk);
3753            v.control_root = Some(new_control_root);
3754            v
3755        };
3756        let expected: Vec<PublicKey> = {
3757            let banlist = fetch_authority(transport, &verify_view).await.banned;
3758            snapshot_members.iter().copied()
3759                .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
3760                .collect()
3761        };
3762        if !expected.is_empty() {
3763            let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
3764            let missing = expected.iter().filter(|m| !folded.contains(m)).count();
3765            if missing > 0 {
3766                return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
3767            }
3768        }
3769
3770        // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
3771        if crate::db::community::community_protocol(cid)?.is_none() {
3772            return Ok(community.clone());
3773        }
3774        let mut updated = community.clone();
3775        updated.community_root = new_root;
3776        updated.root_epoch = new_epoch;
3777        updated.control_pk = Some(new_control_pk);
3778        updated.control_root = Some(new_control_root);
3779        crate::db::community::save_community_v2(&updated)?;
3780        crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
3781        for (h, _) in &carried {
3782            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
3783        }
3784        Ok(updated)
3785    })
3786    .await
3787}
3788
3789/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
3790/// already archived from a prior (aborted) attempt — so a retried Refounding re-
3791/// delivers the SAME key at the same epoch/address instead of double-minting two roots
3792/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
3793/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
3794/// sentinel for a base rotation, else the channel_id hex.
3795fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
3796    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
3797        return Ok(existing);
3798    }
3799    let fresh = crate::community::random_32();
3800    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
3801    Ok(fresh)
3802}
3803
3804// ── The Community List (kind 33302, CORD-02 §8) ──────────────────────────────
3805
3806/// This community's MEMBERSHIP subset for the Community List (CORD-02 §8): never the
3807/// icon (a rehydrating device folds it from the Control Plane), never the link
3808/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
3809fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
3810    let hex = crate::simd::hex::bytes_to_hex_32;
3811    let channels = community
3812        .channels
3813        .iter()
3814        .filter(|c| c.private)
3815        // Keyed channels ONLY. A keyless entry is readable by this build but is
3816        // rejected outright by shipped ones (their `key` is a required String),
3817        // so emitting one would strand every older client on a stale list.
3818        .filter_map(|c| {
3819            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone(), extra: Default::default() })
3820        })
3821        .collect();
3822    super::list::JoinMaterial {
3823        community_id: hex(&community.identity.community_id.0),
3824        owner: hex(&community.identity.owner_xonly),
3825        owner_salt: hex(&community.identity.owner_salt),
3826        community_root: hex(&community.community_root),
3827        root_epoch: community.root_epoch.0,
3828        control_pk: community.control_pk.map(|p| p.to_hex()),
3829        // The list carries every private key its holder has (CORD-02 §8) — a
3830        // staffer's own devices must be able to write. Same trust class as the
3831        // community_root beside it: NIP-44-encrypted to self.
3832        control_root: community.control_root.map(|r| hex(&r)),
3833        channels,
3834        relays: community.relays.clone(),
3835        name: community.name.clone(),
3836        extra: Default::default(),
3837    }
3838}
3839
3840/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
3841/// (the material IS the membership subset of a bundle). The owner root is still
3842/// verified over the network before the community is trusted (accept_bundle).
3843fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
3844    // A keyless listing records that the channel EXISTS, not a grant — there is
3845    // nothing to seat, and it keys up when access is granted.
3846    let channels = jm
3847        .channels
3848        .iter()
3849        .filter_map(|c| {
3850            c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
3851        })
3852        .collect();
3853    CommunityInvite {
3854        community_id: jm.community_id.clone(),
3855        owner: jm.owner.clone(),
3856        owner_salt: jm.owner_salt.clone(),
3857        community_root: jm.community_root.clone(),
3858        root_epoch: jm.root_epoch,
3859        control_pk: jm.control_pk.clone(),
3860        channels,
3861        relays: jm.relays.clone(),
3862        name: jm.name.clone(),
3863        icon: None,
3864        expires_at: None,
3865        creator_npub: None,
3866        label: None,
3867        extra: Default::default(),
3868    }
3869}
3870
3871/// The account's OWN relays — where an account-level list belongs. Published to a
3872/// community's relays instead, the list stops being readable the moment that
3873/// community is left, which is exactly when its tombstone has to be found.
3874/// Empty without a client (tests), where the caller falls back to the held set.
3875async fn own_list_relays() -> Vec<String> {
3876    let Some(client) = crate::state::nostr_client() else { return Vec::new() };
3877    client
3878        .relays()
3879        .await
3880        .iter()
3881        .filter(|(_, r)| r.capabilities().load().can_write())
3882        .map(|(url, _)| url.to_string())
3883        .collect()
3884}
3885
3886/// Read the list from everywhere a copy could be: our own relays, the stock CORD
3887/// set (where it lands when ours refuse the kind), and the held communities'
3888/// (where every list published before the move still lives). Missing one of these
3889/// reads a stale copy and republishes it over a newer tombstone.
3890async fn list_read_relays(extra: &[String]) -> Vec<String> {
3891    let mut set = own_list_relays().await;
3892    set.extend(invite::stock_relays());
3893    set.extend(held_v2_relays());
3894    set.extend(extra.iter().cloned());
3895    set.sort();
3896    set.dedup();
3897    set
3898}
3899
3900/// What a fragment fetch found: the unioned list, and each index's `created_at`
3901/// so the next write to that fragment can exceed it.
3902pub struct FragSet {
3903    pub list: super::list::CommunityList,
3904    pub created_at: std::collections::BTreeMap<usize, u64>,
3905    /// `frags` as declared by the newest fragment seen.
3906    pub declared: usize,
3907    /// The winning parsed fragment per index — what the relays currently hold,
3908    /// so a rewrite can skip publishing byte-identical fragments.
3909    pub read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList>,
3910}
3911
3912impl FragSet {
3913    /// Coverage, not agreement: we hold every index below `declared`, whatever
3914    /// their individual ages. A short read is read-only, never a write.
3915    pub fn is_complete(&self) -> bool {
3916        (0..self.declared).all(|i| self.created_at.contains_key(&i))
3917    }
3918}
3919
3920/// Fetch the account's 33302 fragments and union them. Newest wins PER INDEX —
3921/// each fragment is its own addressable coordinate, so they age independently.
3922async fn fetch_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<FragSet>, String> {
3923    let signer = crate::signer::active_signer()?;
3924    let my_pk = me_pk()?;
3925    let query = Query {
3926        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
3927        authors: vec![my_pk.to_hex()],
3928        limit: Some(64),
3929        ..Default::default()
3930    };
3931    let events = transport.fetch(&query, relays).await?;
3932    if events.is_empty() {
3933        return Ok(None);
3934    }
3935    let mut newest: std::collections::BTreeMap<usize, (u64, [u8; 32], super::list_frag::FragList)> = Default::default();
3936    let mut undecryptable = 0usize;
3937    for e in events {
3938        let at = e.created_at.as_secs();
3939        let id = *e.id.as_bytes();
3940        match super::list_frag::parse_fragment_event(&signer, my_pk, &e).await {
3941            Ok((index, frag)) => {
3942                // Mirror relay resolution (CORD-02 §8): newest created_at holds the
3943                // coordinate, an age tie falls to the LOWEST event id.
3944                let wins = match newest.get(&index) {
3945                    None => true,
3946                    Some((prev_at, prev_id, _)) => at > *prev_at || (at == *prev_at && id < *prev_id),
3947                };
3948                if wins {
3949                    newest.insert(index, (at, id, frag));
3950                }
3951            }
3952            Err(_) => undecryptable += 1,
3953        }
3954    }
3955    if newest.is_empty() {
3956        crate::log_warn!("[CommunityList] {} fragment(s) fetched, none readable — treating as no news", undecryptable);
3957        return Ok(None);
3958    }
3959    // The newest fragment governs `frags`; an age tie resolves to the LARGER count
3960    // (CORD-02 §8) — too large reads an index that turns out empty, too small sends
3961    // live fragments out of range and dormant.
3962    let declared = newest
3963        .values()
3964        .max_by_key(|(at, _, f)| (*at, f.frags))
3965        .map(|(_, _, f)| f.frags)
3966        .unwrap_or(1)
3967        .max(1);
3968    let read_frags: std::collections::BTreeMap<usize, super::list_frag::FragList> =
3969        newest.iter().map(|(i, (_, _, f))| (*i, f.clone())).collect();
3970    let frags: Vec<_> = read_frags.values().cloned().collect();
3971    let set = FragSet {
3972        list: super::list_frag::defragment(&frags),
3973        created_at: newest.iter().map(|(i, (at, _, _))| (*i, *at)).collect(),
3974        declared,
3975        read_frags,
3976    };
3977    if !set.is_complete() {
3978        crate::log_warn!(
3979            "[CommunityList] INCOMPLETE: hold {} of {} fragment(s) — reading, refusing to write",
3980            set.created_at.len(),
3981            declared
3982        );
3983    }
3984    Ok(Some(set))
3985}
3986
3987/// Publish a list as fragments. Each fragment's `created_at` must exceed that
3988/// fragment's own previous value — relays resolve an addressable event on
3989/// `created_at` alone and break a tie on the lowest event id, so a same-second
3990/// rewrite can silently discard the newer content.
3991async fn publish_fragments<T: Transport + ?Sized>(
3992    transport: &T,
3993    list: &super::list::CommunityList,
3994    prev: &std::collections::BTreeMap<usize, u64>,
3995    read: &std::collections::BTreeMap<usize, super::list_frag::FragList>,
3996    own: &[String],
3997) -> Result<usize, String> {
3998    let signer = crate::signer::active_signer()?;
3999    let my_pk = me_pk()?;
4000    let frags = super::list_frag::fragment(list);
4001    let now = now_ms() / 1000;
4002    // A fragment serializing to the bytes we just read is already on the relay:
4003    // republishing it only churns created_at — and hands a same-second sibling
4004    // write a lowest-id coin-flip it didn't need to enter.
4005    let unchanged = |index: usize, frag: &super::list_frag::FragList| -> bool {
4006        let Some(old) = read.get(&index) else { return false };
4007        matches!(
4008            (serde_json::to_string(old), serde_json::to_string(frag)),
4009            (Ok(a), Ok(b)) if a == b
4010        )
4011    };
4012    // Persisted, not level-gated: a List write that silently never lands is the failure
4013    // mode this whole format exists to end, and it is only ever diagnosed after the fact.
4014    crate::log_net_info!(
4015        "[CommunityList] publishing {} fragment(s): {} live entries ({} retired), {} tombstones",
4016        frags.len(),
4017        frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4018        list.entries.len() - frags.iter().map(|f| f.entries.len()).sum::<usize>(),
4019        frags.iter().map(|f| f.tombstones.len()).sum::<usize>(),
4020    );
4021    let mut skipped = 0usize;
4022    for (index, frag) in frags.iter().enumerate() {
4023        if unchanged(index, frag) {
4024            skipped += 1;
4025            continue;
4026        }
4027        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4028        let event = super::list_frag::build_fragment_event(&signer, my_pk, frag, index, created_at).await?;
4029        publish_list_event(transport, &event, own).await?;
4030    }
4031    // A shrunk set leaves the fragments above it in place; empty them so a later
4032    // growth into that index cannot re-read stale memberships.
4033    for index in frags.len()..prev.len() {
4034        let empty = super::list_frag::FragList {
4035            frags: frags.len(),
4036            entries: vec![],
4037            tombstones: vec![],
4038            extra: Default::default(),
4039        };
4040        if unchanged(index, &empty) {
4041            skipped += 1;
4042            continue;
4043        }
4044        let created_at = now.max(prev.get(&index).copied().unwrap_or(0) + 1);
4045        let event = super::list_frag::build_fragment_event(&signer, my_pk, &empty, index, created_at).await?;
4046        publish_list_event(transport, &event, own).await?;
4047    }
4048    if skipped > 0 {
4049        crate::log_net_info!("[CommunityList] {} fragment(s) skipped — byte-identical to the relay copy", skipped);
4050    }
4051    Ok(frags.len())
4052}
4053
4054/// Publish an account-level list, falling back to the stock CORD relays when our
4055/// own reject it — 33302 is a custom kind, and a relay with a kind whitelist drops
4056/// it silently. The list is the vault: it has to land somewhere every client reads.
4057async fn publish_list_event<T: Transport + ?Sized>(transport: &T, event: &Event, own: &[String]) -> Result<(), String> {
4058    if !own.is_empty() {
4059        match transport.publish(event, own).await {
4060            Ok(()) => return Ok(()),
4061            Err(e) => crate::log_warn!("[CommunityList] own relays refused the list ({}) — falling back to the stock set", e),
4062        }
4063    }
4064    transport.publish(event, &invite::stock_relays()).await
4065}
4066
4067/// The union of every held v2 community's relays — a fallback write target, and
4068/// where every list published before the move to our own relays still lives.
4069fn held_v2_relays() -> Vec<String> {
4070    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4071    if let Ok(ids) = crate::db::community::list_community_ids() {
4072        for id in ids {
4073            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4074                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
4075                    set.extend(c.relays);
4076                }
4077            }
4078        }
4079    }
4080    set.into_iter().collect()
4081}
4082
4083/// Rebuild this account's Community List from its held v2 communities, MERGE with the remote
4084/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
4085/// `just_joined` is the community THIS call is recording a create/join for — the
4086/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
4087/// (a deliberate re-join resurrects). Every OTHER held community that the remote
4088/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
4089/// because we joined something else — the W1 resurrection hole). Idempotent;
4090/// best-effort — a list-publish failure never fails the membership change itself.
4091/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
4092/// skipped without failing the caller (a failed remote fetch — see below). Callers that
4093/// need the membership to actually land use [`republish_community_list_durable`].
4094pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
4095    crate::db::scoped(async move {
4096        let held = held_v2_relays();
4097        if held.is_empty() {
4098            return Ok(false); // nothing held → nothing to sync
4099        }
4100        let relays = list_read_relays(&[]).await;
4101        let own = own_list_relays().await;
4102        // Written to our own relays, read from both: the copy has to outlive any
4103        // one membership. Falls back to the held set when we have no relays of
4104        // our own to write to.
4105        let write_relays = if own.is_empty() { held.clone() } else { own };
4106        // A FAILED remote fetch must not drive this replaceable-event write: publishing
4107        // a list built without the remote seeds would drop older-epoch backfill anchors
4108        // and re-stamp add-times (the W2 seed-regression + a resurrection window).
4109        let (remote, prev_created, read_frags) = match fetch_fragments(transport, &relays).await {
4110            // No fragments yet: this is the first write under §8, and local state
4111            // is the source. Nothing to merge, nothing to lose.
4112            Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4113            Ok(Some(set)) if !set.is_complete() => {
4114                crate::log_net_fail!(
4115                    "[CommunityList] republish SKIPPED — hold {} of {} fragments; rewriting a set we haven't fully read would drop the memberships in the ones we're missing",
4116                    set.created_at.len(),
4117                    set.declared
4118                );
4119                return Ok(false);
4120            }
4121            Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4122            Err(e) => {
4123                // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
4124                // remote seeds drops backfill anchors), but the membership this call was meant
4125                // to record is now simply unrecorded. A join that lands here leaves a community
4126                // held locally with no list entry — and if it also carries an older tombstone,
4127                // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
4128                crate::log_warn!(
4129                    "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
4130                    e,
4131                    just_joined
4132                        .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
4133                        .unwrap_or_default()
4134                );
4135                return Ok(false);
4136            }
4137        };
4138        let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
4139        let now = now_ms();
4140        let mut local = super::list::CommunityList::default();
4141        for id in crate::db::community::list_community_ids()? {
4142            if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
4143                continue;
4144            }
4145            let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
4146            let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
4147            let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
4148            // A held community the remote has tombstoned (a sibling device left it) that
4149            // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
4150            // different community would silently undo the leave everywhere.
4151            //
4152            // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
4153            // reached the list (this publish is best-effort — a failed remote fetch
4154            // silently skips it) leaves a tombstone with no entry, and nothing can ever
4155            // out-rank it again: every boot the list sync reads "removed", tears the
4156            // community down, the rejoin re-adds it, and it loops forever. Our own hold
4157            // is first-hand evidence of membership, so let it settle the tie by the same
4158            // add-vs-remove rule the list already uses everywhere else.
4159            let tombstoned_at = remote
4160                .tombstones
4161                .iter()
4162                .find(|t| t.community_id == cid_hex)
4163                .map(|t| t.removed_at)
4164                .unwrap_or(0);
4165            let held_since = c.created_at_ms;
4166            if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
4167                crate::log_warn!(
4168                    "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
4169                    &cid_hex[..8], tombstoned_at, held_since
4170                );
4171                continue;
4172            }
4173            // Keep an already-live entry's add time (no churn); the joined community (or a
4174            // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
4175            // that outlived a tombstone re-asserts itself at its own join time, which is
4176            // already newer than the removal.
4177            let added_at = if remote.is_live(&cid_hex) && !is_join {
4178                remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
4179            } else if !is_join && tombstoned_at > 0 {
4180                held_since
4181            } else {
4182                now
4183            };
4184            let jm = join_material(&c);
4185            local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
4186        }
4187        let merged = remote.merge(&local);
4188        // No whole-list cap: fragmentation is what keeps each event publishable,
4189        // and the count limit is gone (CORD-02 §8).
4190        match publish_fragments(transport, &merged, &prev_created, &read_frags, &write_relays).await {
4191            Ok(_) => {}
4192            Err(e) => {
4193                crate::log_net_fail!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
4194                return Err(e);
4195            }
4196        }
4197        Ok(true)
4198    })
4199    .await
4200}
4201
4202/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
4203/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
4204/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
4205/// instead of spinning.
4206const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
4207
4208/// Record a membership across devices DURABLY: retry in the background until the list
4209/// actually lands.
4210///
4211/// [`republish_community_list`] must never fail a join, and it deliberately publishes
4212/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
4213/// other devices' entries). One shot at that means a relay blip during a join leaves the
4214/// membership unrecorded until the user happens to join something else — and if a stale
4215/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
4216///
4217/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
4218/// generic fn directly). Bound to its account, so a swap mid-backoff leaves it
4219/// publishing A's list from A's client rather than from B's.
4220pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
4221    if crate::state::nostr_client().is_none() {
4222        return;
4223    }
4224    crate::db::spawn_bound(async move {
4225        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4226            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4227            match republish_community_list(&transport, just_joined.as_ref()).await {
4228                Ok(true) => {
4229                    if attempt > 0 {
4230                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
4231                    }
4232                    return;
4233                }
4234                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
4235                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
4236            }
4237            crate::rt::time::sleep(std::time::Duration::from_secs(*wait)).await;
4238        }
4239        crate::log_warn!(
4240            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
4241            LIST_REPUBLISH_BACKOFF_SECS.len()
4242        );
4243    });
4244}
4245
4246/// Record a permanent leave tombstone for `community_id` in the List. Written to
4247/// our OWN relays: the departing community's are about to stop being read at all,
4248/// so a tombstone left only there is one no later sync can ever fetch — the
4249/// community then rejoins itself from a stale copy on the next boot.
4250async fn tombstone_community_list<T: Transport + ?Sized>(
4251    transport: &T,
4252    community_id: &crate::community::CommunityId,
4253    relays: &[String],
4254    removed_at: u64,
4255) -> Result<(), String> {
4256    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
4257    let read_relays = list_read_relays(relays).await;
4258    let own = own_list_relays().await;
4259    let write_relays = if own.is_empty() { relays.to_vec() } else { own };
4260    // A failed fetch here would drop other communities' entries (only the
4261    // tombstone would survive); preserve them by bailing — the leave re-records
4262    // on the next attempt, and the local teardown already happened.
4263    let (mut doc, prev_created, read_frags) = match fetch_fragments(transport, &read_relays).await {
4264        Ok(Some(set)) if !set.is_complete() => {
4265            return Err(format!("hold {} of {} fragments — refusing to rewrite a set we haven't fully read", set.created_at.len(), set.declared));
4266        }
4267        Ok(Some(set)) => (set.list, set.created_at, set.read_frags),
4268        Ok(None) => (super::list::CommunityList::default(), Default::default(), Default::default()),
4269        Err(e) => return Err(e),
4270    };
4271    doc.tombstones.retain(|t| t.community_id != cid_hex);
4272    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at, extra: Default::default() });
4273    // No size gate on the way out: a tombstone strictly shrinks the live set, and
4274    // a guard that blocks the only operation able to restore compliance is a
4275    // deadlock, not a guard (CORD-02 §8).
4276    publish_fragments(transport, &doc, &prev_created, &read_frags, &write_relays).await.map(|_| ())
4277}
4278
4279/// Retry a leave tombstone until it lands, on the same budget a join gets. A leave
4280/// that fails to record is the worse of the two: the local hold is already gone, so
4281/// this device shows the community left while every other one still holds it, and
4282/// nothing re-records it until some unrelated edit happens to carry it.
4283///
4284/// `removed_at` is the caller's, not `now` — re-stamping it here would let a retry
4285/// that fires after a genuine rejoin bury that rejoin.
4286pub fn tombstone_community_list_durable(community_id: crate::community::CommunityId, relays: Vec<String>, removed_at: u64) {
4287    if crate::state::nostr_client().is_none() {
4288        return;
4289    }
4290    crate::db::spawn_bound(async move {
4291        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
4292            crate::rt::time::sleep(std::time::Duration::from_secs(*wait)).await;
4293            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4294            match tombstone_community_list(&transport, &community_id, &relays, removed_at).await {
4295                Ok(()) => {
4296                    crate::log_net_info!(
4297                        "[CommunityList] leave tombstone for {} landed on retry #{}",
4298                        &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4299                        attempt + 1
4300                    );
4301                    return;
4302                }
4303                Err(e) => crate::log_warn!("[CommunityList] leave tombstone retry #{} failed: {}", attempt + 1, e),
4304            }
4305        }
4306        crate::log_net_fail!(
4307            "[CommunityList] leave tombstone for {} NEVER landed after {} attempts — it will come back on another device",
4308            &crate::simd::hex::bytes_to_hex_32(&community_id.0)[..8],
4309            LIST_REPUBLISH_BACKOFF_SECS.len()
4310        );
4311    });
4312}
4313
4314/// Sync memberships from the Community List across devices: fetch this account's list from
4315/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
4316/// a fresh device), and JOIN every live entry not already held — reconstructing the
4317/// community from its join material and re-verifying the owner root. Returns the
4318/// newly-rehydrated communities (so the caller can subscribe + notify).
4319/// What one Community-List sync changed locally.
4320pub struct ListSyncOutcome {
4321    /// Communities newly adopted from the list (already persisted + chat-registered).
4322    pub joined: Vec<CommunityV2>,
4323    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
4324    ///
4325    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
4326    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
4327    /// and it can't look them up afterwards. Deleting the community while leaving its chat
4328    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
4329    pub removed: Vec<(String, Vec<String>)>,
4330}
4331
4332/// Marks that this account has written the fragmented list at least once.
4333const LIST_SEEDED_KEY: &str = "community_list_frag_seeded";
4334
4335/// The join refusal for a VERIFIED dissolution — an owner-signed, identity-bound
4336/// tombstone (§9: no un-dissolve). A sentinel because the accept boundary matches
4337/// on it: this is the one join failure that is a verdict rather than a maybe, so
4338/// the parked invite is retired instead of kept for a retry that can never succeed.
4339pub const ERR_DISSOLVED: &str = "this community has been dissolved";
4340
4341/// Retire a parked invite whose community is provably dead: drop the local row and
4342/// tombstone the §8 List so every sibling device purges its copy on the next sync.
4343/// ONLY for the dissolved verdict — ambiguous could-not-verify failures stay parked.
4344pub async fn retire_dead_invite<T: Transport + ?Sized>(transport: &T, community_id_hex: &str, relays: &[String]) {
4345    let _ = crate::db::community::delete_pending_invite(community_id_hex);
4346    if let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(community_id_hex) {
4347        let id = crate::community::CommunityId(cid);
4348        let at = now_ms();
4349        if tombstone_community_list(transport, &id, relays, at).await.is_err() {
4350            // The row is already gone locally; make sure the suppression still
4351            // reaches sibling devices once the relays cooperate.
4352            tombstone_community_list_durable(id, relays.to_vec(), at);
4353        }
4354    }
4355    crate::emit_event("community_invites_purged", &serde_json::json!({}));
4356}
4357
4358/// Every relay answered EOSE with zero fragments — the only reading of "empty"
4359/// strong enough to seed over. The aggregate fetch that precedes this cannot
4360/// distinguish "no fragments" from "the relay holding them never answered";
4361/// asked one at a time, an error is a FAILED read and a failed read never seeds.
4362async fn confirmed_no_fragments<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> bool {
4363    let Ok(my_pk) = me_pk() else { return false };
4364    if relays.is_empty() {
4365        return false;
4366    }
4367    let query = Query {
4368        kinds: vec![super::kind::COMMUNITY_LIST_FRAG],
4369        authors: vec![my_pk.to_hex()],
4370        limit: Some(1),
4371        ..Default::default()
4372    };
4373    for relay in relays {
4374        match transport.fetch(&query, std::slice::from_ref(relay)).await {
4375            Ok(events) if events.is_empty() => {}
4376            // Found one (the aggregate read raced a sibling's write) or the relay
4377            // didn't answer — either way this account is not confirmed empty.
4378            _ => return false,
4379        }
4380    }
4381    true
4382}
4383
4384/// First write of the §8 List for an account that has none: an account upgrading
4385/// from the retired single-event list arrives here with memberships that exist
4386/// only in local state, and nothing else in the stack publishes without a
4387/// membership change to record. Latched once it lands, so a boot-load read that
4388/// comes back empty can never republish local state over a sibling's tombstones.
4389async fn seed_community_list<T: Transport + ?Sized>(transport: &T) {
4390    if crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).ok().flatten().is_some() {
4391        return;
4392    }
4393    if held_v2_relays().is_empty() {
4394        return;
4395    }
4396    // Seeding over a read that merely FAILED publishes fragment 0 over a sibling
4397    // device's tombstones at a fresh created_at — confirm the emptiness per relay.
4398    let own = own_list_relays().await;
4399    let confirm = if own.is_empty() { held_v2_relays() } else { own };
4400    if !confirmed_no_fragments(transport, &confirm).await {
4401        crate::log_warn!("[CommunityList] seed DEFERRED — the empty read is unconfirmed; retrying on a later boot");
4402        return;
4403    }
4404    crate::log_net_info!("[CommunityList] no fragments held anywhere (confirmed per relay) — seeding from local state");
4405    match republish_community_list(transport, None).await {
4406        Ok(true) => {
4407            let _ = crate::db::settings::set_sql_setting(LIST_SEEDED_KEY.to_string(), "1".to_string());
4408        }
4409        Ok(false) => {}
4410        Err(e) => crate::log_net_fail!("[CommunityList] seed failed: {e}"),
4411    }
4412}
4413
4414pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
4415    crate::db::scoped(async move {
4416        let relays = list_read_relays(bootstrap_relays).await;
4417        if relays.is_empty() {
4418            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4419        }
4420        // A cross-device sync that finds nothing is indistinguishable from one that
4421        // never ran, so every exit says why — this path is only ever debugged after
4422        // the fact, from a user's log.
4423        let list = match fetch_fragments(transport, &relays).await {
4424            Ok(Some(set)) => {
4425                crate::log_net_info!(
4426                    "[CommunityList] fetched: {} of {} fragment(s), {} entries, {} tombstones, across {} relays",
4427                    set.created_at.len(),
4428                    set.declared,
4429                    set.list.entries.len(),
4430                    set.list.tombstones.len(),
4431                    relays.len()
4432                );
4433                set.list
4434            }
4435            Ok(None) => {
4436                // Transient by nature: boot runs many concurrent passes and a relay that
4437                // times out under that load returns nothing. Only persistent absence
4438                // matters, and that shows up as "adopted nothing" anyway.
4439                crate::log_debug!("[CommunityList] no fragments across {} relays", relays.len());
4440                seed_community_list(transport).await;
4441                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4442            }
4443            Err(e) => {
4444                crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
4445                return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
4446            }
4447        };
4448        // Receive-side teardown (the counterpart to the republish tombstone guard):
4449        // a community this device still holds but the synced list shows TOMBSTONED (a
4450        // sibling device left it) and NOT live gets torn down here, so a leave on one
4451        // device propagates to the others. A re-join would have re-added it live
4452        // (beating the tombstone), so is_live short-circuits the honest case.
4453        let mut removed: Vec<(String, Vec<String>)> = Vec::new();
4454        for t in &list.tombstones {
4455            if list.is_live(&t.community_id) {
4456                continue;
4457            }
4458            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
4459            let id = crate::community::CommunityId(cid);
4460            let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
4461                continue; // not held — nothing to tear down
4462            };
4463            // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
4464            // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
4465            // this would then delete the community on every sync. So let the LOCAL hold break the
4466            // tie too — a hold created after the removal IS the rejoin, whether or not its entry
4467            // ever reached the list. Same rule the v1 sweep uses.
4468            if held.created_at_ms > t.removed_at {
4469                crate::log_warn!(
4470                    "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
4471                    &t.community_id[..8], t.removed_at, held.created_at_ms
4472                );
4473                continue;
4474            }
4475            let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
4476            let _ = crate::db::community::delete_community(&t.community_id);
4477            removed.push((t.community_id.clone(), channel_ids));
4478        }
4479        // A tombstone also retires any PARKED invite it post-dates — a decline or a
4480        // dissolution-retire made on another device. Supersession, not a bare id
4481        // match: only a removal NEWER than the invite's arrival is a verdict on it;
4482        // an older tombstone is a past leave the re-invite already superseded
4483        // (received_at is seconds, removed_at is ms).
4484        let mut invites_purged = 0usize;
4485        for t in &list.tombstones {
4486            if list.is_live(&t.community_id) {
4487                continue;
4488            }
4489            let Ok(Some(received_at)) = crate::db::community::pending_invite_received_at(&t.community_id) else { continue };
4490            if (received_at.max(0) as u64).saturating_mul(1000) <= t.removed_at
4491                && crate::db::community::delete_pending_invite(&t.community_id).is_ok()
4492            {
4493                invites_purged += 1;
4494            }
4495        }
4496        if invites_purged > 0 {
4497            crate::emit_event("community_invites_purged", &serde_json::json!({}));
4498        }
4499        let mut joined = Vec::new();
4500        for entry in list.live_entries() {
4501            let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
4502            // Held under ANY protocol, not just v2. A protocol-scoped check re-adopts an
4503            // id we already hold as v1: verification correctly fails (there is no v2
4504            // community at that coordinate), and the entry is retried on EVERY sync pass
4505            // forever — a permanent warning flood plus a wasted multi-relay walk each time.
4506            if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
4507                continue; // already held
4508            }
4509            // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
4510            // and seeds floors. NO Guestbook Join: this device is receiving keys the
4511            // account already holds elsewhere — the membership was announced when it
4512            // actually joined, and a key sync is not a membership event.
4513            let bundle = material_to_invite(&entry.current);
4514            match accept_bundle(transport, &bundle, None, false).await {
4515                Ok(community) => {
4516                    // The staff write secret never rides a bundle shape — adopt it
4517                    // from the list entry directly, and only when it derives to the
4518                    // control_pk held for exactly this epoch (CORD-02 §5/§8). A
4519                    // stale secret fails closed to read-only; a 136-byte base blob
4520                    // or the Grant's control_wrap re-delivers on the walk forward.
4521                    if let (Some(pk), Some(root_hex)) = (community.control_pk, entry.current.control_root.as_deref()) {
4522                        if let Some(root) = crate::simd::hex::hex_to_bytes_32_checked(root_hex) {
4523                            if super::derive::control_signer_group_key(&root, community.id(), community.root_epoch).pk() == pk
4524
4525                            {
4526                                let mut held = community.clone();
4527                                held.control_root = Some(root);
4528                                let _ = crate::db::community::save_community_v2(&held);
4529                            }
4530                        }
4531                    }
4532                    joined.push(community);
4533                }
4534                // A listed-but-unadoptable entry is the failure mode that reads as
4535                // "cross-device sync is broken": the community never appears and any
4536                // parked invite for it is never retired.
4537                Err(e) => crate::log_net_fail!(
4538                    "[CommunityList] {} is listed but adoption failed: {e}",
4539                    &entry.community_id[..entry.community_id.len().min(8)]
4540                ),
4541            }
4542        }
4543        Ok(ListSyncOutcome { joined, removed })
4544    })
4545    .await
4546}
4547
4548// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
4549
4550/// Publish one control edition (a role, grant, banlist, community-metadata, or
4551/// channel-metadata edit) at the next version for its entity, chaining `prev` from
4552/// our held head, and advance our local floor. Authority is enforced by every
4553/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
4554/// requires only a valid local signer; a well-behaved client checks its own rank
4555/// first, but a reader drops an unauthorized edition regardless.
4556/// This actor's authority citation for a control edition (CORD-04 §5): the head
4557/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
4558///
4559/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
4560/// at least this Grant, then resolves rank against its CURRENT roster, so a
4561/// demoted admin is never grandfathered by an old-but-once-valid citation.
4562///
4563/// `None` for the owner (supreme, rank comes from the community id) and `None`
4564/// when no Grant head is held — an actor who cannot cite has no rank to claim,
4565/// and the edition is dropped by a conforming reader either way.
4566/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
4567/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
4568/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
4569/// cited version with the cited hash at the tip — else fail closed, because
4570/// honoring an action whose authority we can't confirm is exactly how a demoted
4571/// moderator keeps moderating.
4572///
4573/// Completeness only: the permission + outrank is the separate roster check, so a
4574/// since-demoted actor is refused there (refuse-superseded). An action citing a
4575/// version we haven't synced parks and is re-judged on the next roster sync — the
4576/// sync path can't escalate to a blocking fetch.
4577pub(super) fn citation_is_synced(
4578    cid_hex: &str,
4579    owner_hex: &str,
4580    actor_hex: &str,
4581    citation: Option<&crate::community::edition::AuthorityCitation>,
4582) -> bool {
4583    if owner_hex == actor_hex {
4584        return true;
4585    }
4586    if citation.is_none() {
4587        return false;
4588    }
4589    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
4590    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
4591    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
4592        &crate::community::CommunityId(cid_bytes),
4593        &actor_bytes,
4594    ));
4595    let head: Vec<crate::community::roster::EntityHead> =
4596        crate::db::community::get_edition_head(cid_hex, &grant_hex)
4597            .ok()
4598            .flatten()
4599            .map(|(version, self_hash)| crate::community::roster::EntityHead {
4600                entity_hex: grant_hex.clone(),
4601                version,
4602                self_hash,
4603                inner_id: [0u8; 32],
4604                citation: None,
4605            })
4606            .into_iter()
4607            .collect();
4608    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
4609}
4610
4611/// [`my_authority_citation`], but refusing to emit an action every reader will
4612/// drop (CORD-04 §5: an uncited non-owner action is not honored).
4613///
4614/// The citation is built from PERSISTED heads, which only `follow_control` writes
4615/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
4616/// otherwise publish uncited and have the action silently vanish on every client,
4617/// with nothing shown locally. Failing here turns that into one retryable error.
4618fn required_authority_citation(
4619    community: &CommunityV2,
4620    actor: &PublicKey,
4621) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
4622    if community.owner().ok().as_ref() == Some(actor) {
4623        return Ok(None); // supreme, cites nothing
4624    }
4625    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
4626        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
4627    })
4628}
4629
4630fn my_authority_citation(
4631    community: &CommunityV2,
4632    actor: &PublicKey,
4633) -> Option<crate::community::edition::AuthorityCitation> {
4634    if community.owner().ok().as_ref() == Some(actor) {
4635        return None;
4636    }
4637    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
4638    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4639    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
4640    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
4641        .ok()
4642        .flatten()
4643        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
4644}
4645
4646/// Refuse a root-derived write whose in-hand struct predates a rotation.
4647///
4648/// A Ban's refound buries the old root while the caller's `CommunityV2` still
4649/// points at it; publishing there lands on a plane nobody folds — the action
4650/// "succeeds" and silently never happened (an unban that doesn't unban, an
4651/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
4652/// the caller reload and retry against the living root.
4653fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
4654    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4655    match crate::db::community::get_server_root_epoch(&cid_hex)? {
4656        Some(held) if held != community.root_epoch.0 => Err(format!(
4657            "the community re-founded mid-action (epoch {} -> {held}); retry",
4658            community.root_epoch.0
4659        )),
4660        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
4661    }
4662}
4663
4664async fn publish_control_edition<T: Transport + ?Sized>(
4665    transport: &T,
4666    community: &CommunityV2,
4667    vsk: &str,
4668    entity_id: &[u8; 32],
4669    content: &str,
4670) -> Result<(), String> {
4671    crate::db::scoped(async move {
4672        assert_current_root(community)?;
4673        let signer = crate::signer::active_signer()?;
4674        let my_pk = me_pk()?;
4675        // On a split epoch the signing secret is the staff-held control_root
4676        // (CORD-02 §2) — a member without it fails HERE with a readable error
4677        // instead of minting a wrap every reader and relay drops.
4678        let view = control::ControlPlane::of(community);
4679        let control = view.write_group()?;
4680        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4681        let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
4682        let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
4683            Some((v, h)) => (v + 1, Some(h)),
4684            None => (1, None),
4685        };
4686        // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
4687        // under. Computed here rather than passed in — the citation is a property of
4688        // WHO IS ACTING, identical for every entity kind, so deciding it per call
4689        // site is nine chances to forget (and nine were, silently: every site passed
4690        // None). The owner cites nothing; their rank is the community id itself.
4691        let citation = required_authority_citation(community, &my_pk)?;
4692        let at = now_ms() / 1000;
4693        let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
4694        let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
4695        transport.publish(&wrap, &community.relays).await?;
4696        // Advance our own floor so a follow-up edit chains from this head and refuse-
4697        // downgrade holds; open our own wrap to recover the self_hash + inner_id.
4698        // Re-check the session AFTER the publish await: a swap mid-publish means the
4699        // pool now points at another account's DB — skipping is safe (the next own
4700        // edit rebuilds the same head from the relay's copy).
4701        if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
4702            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
4703        }
4704        Ok(())
4705    })
4706    .await
4707}
4708
4709/// Merge our OWN just-published Role/Grant into the locally stored roster.
4710///
4711/// v2 persists the roster only inside `follow_control`, so a role or grant we
4712/// just published is invisible to every sync local read (entitlement, capability
4713/// gates, the next grant) until the next fold. This writes what we are already
4714/// authorized to have written; the next fold recomputes from the plane and
4715/// converges. Mirrors the fold's own write, so the stored `roles_at` is left
4716/// alone — a real edition always outranks this optimistic merge.
4717fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
4718    let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
4719    if let Some(r) = role {
4720        match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
4721            Some(slot) => *slot = r.clone(),
4722            None => roster.roles.push(r.clone()),
4723        }
4724    }
4725    if let Some(g) = grant {
4726        match roster.grants.iter_mut().find(|x| x.member == g.member) {
4727            Some(slot) => *slot = g.clone(),
4728            None => roster.grants.push(g.clone()),
4729        }
4730    }
4731    let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
4732    if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
4733        crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
4734    }
4735}
4736
4737/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
4738/// rename or permission change is a versioned edit of the same id. Gated on the
4739/// reader side by `MANAGE_ROLES` + outrank.
4740pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
4741    super::roles::validate_role(role)?;
4742    let content = super::roles::role_content_json(role)?;
4743    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
4744    publish_control_edition(transport, community, vsk::ROLE, &role_id, &content).await
4745}
4746
4747/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
4748/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
4749/// member. Staff-ness (whether the Grant must deliver the `control_root`,
4750/// CORD-04 §3) is judged from the persisted roster; internal callers holding a
4751/// fresher fetched view use [`grant_roles_with_roster`].
4752pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
4753    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4754    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4755    // A role this device hasn't folded is unjudgeable for staff-ness, and
4756    // guessing "not staff" publishes a staff-making Grant with NO control_wrap
4757    // — seating a staffer who cannot write, silently (CORD-04 §3 makes the wrap
4758    // mandatory on such a Grant). Resolve it from the network first; only the
4759    // definitions still missing after that fall back to the local view.
4760    // Internal callers already holding a fetched roster use
4761    // `grant_roles_with_roster` and skip this round trip.
4762    let roster = if community.control_pk.is_some() && role_ids.iter().any(|rid| roster.role(rid).is_none()) {
4763        let mut fetched = fetch_authority(transport, community).await.roles;
4764        for r in roster.roles {
4765            if !fetched.roles.iter().any(|x| x.role_id == r.role_id) {
4766                fetched.roles.push(r);
4767            }
4768        }
4769        fetched
4770    } else {
4771        roster
4772    };
4773    grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4774}
4775
4776/// The `control_wrap` a Grant carries when its role set leaves `member` staff
4777/// (CORD-04 §3): the current epoch's `control_root`, NIP-44-encrypted under
4778/// the granter↔member pairwise key, `epoch_be[8] ‖ control_root[32]` inside.
4779/// `None` when nothing is owed — a legacy pre-split epoch, or a grant that
4780/// leaves the member non-staff. Attached on EVERY staff-leaving grant, not
4781/// just the first: a re-issue with a fresh wrap is the spec's own re-delivery
4782/// path (a lost key, a head superseded before its member fetched it), and the
4783/// marginal cost is one ECDH.
4784///
4785/// Errs when a wrap is owed but this granter cannot mint one: a staff-making
4786/// edition MUST carry a wrap fresh for the current epoch — publishing without
4787/// it would seat a staffer who cannot write, silently. (The publish itself
4788/// would fail on the same missing secret, but the promotion must never outrun
4789/// the delivery.)
4790async fn control_wrap_owed(community: &CommunityV2, makes_staff: bool, member: &PublicKey) -> Result<Option<String>, String> {
4791    if !makes_staff {
4792        return Ok(None);
4793    }
4794    let Some(pk) = community.control_pk else {
4795        return Ok(None); // legacy epoch — the plane has no write key to deliver
4796    };
4797    let Some(cr) = community.control_root else {
4798        return Err("you don't hold this community's staff write key, so you can't promote to staff yet".to_string());
4799    };
4800    // Never deliver a secret that doesn't derive to the held address — the
4801    // recipient's fail-closed check would drop it anyway (CORD-04 §3).
4802    if super::derive::control_signer_group_key(&cr, community.id(), community.root_epoch).pk() != pk {
4803        return Err("this community's held staff write key is corrupt; re-sync before promoting".to_string());
4804    }
4805    use nostr_sdk::prelude::AsyncNip44;
4806    let signer = crate::signer::active_signer()?;
4807    let wrap = signer
4808        .nip44_encrypt_async(member, &super::rekey::control_wrap_b64(community.root_epoch, &cr))
4809        .await
4810        .map_err(|e| format!("staff key delivery: {e}"))?;
4811    Ok(Some(wrap))
4812}
4813
4814/// [`grant_roles`] judging staff-ness against a caller-supplied roster — the
4815/// freshest view the caller holds (a fetched authority merge, or one carrying
4816/// a role minted moments ago that no fold has read back).
4817async fn grant_roles_with_roster<T: Transport + ?Sized>(
4818    transport: &T,
4819    community: &CommunityV2,
4820    member: &PublicKey,
4821    role_ids: Vec<String>,
4822    roster: &crate::community::roles::CommunityRoles,
4823) -> Result<(), String> {
4824    // A grant that leaves the member staff carries the staff write key in the
4825    // edition itself (CORD-04 §3) — promotion and delivery are one signed
4826    // edition: nothing separate to send, race, or watch an inbox for.
4827    let control_wrap = control_wrap_owed(community, roster.roles_make_staff(&role_ids), member).await?;
4828    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
4829    let content = super::roles::grant_content_json_with_wrap(&grant, control_wrap)?;
4830    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
4831    publish_control_edition(transport, community, vsk::GRANT, &eid, &content).await
4832}
4833
4834/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
4835/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
4836/// every device, so concurrent grants converge as editions of ONE entity instead
4837/// of forking two Admin roles.
4838pub async fn ensure_admin_role<T: Transport + ?Sized>(
4839    transport: &T,
4840    community: &CommunityV2,
4841    view: &AuthorityView,
4842    create_if_missing: bool,
4843) -> Result<Option<String>, String> {
4844    use crate::community::roles::{Permissions, Role, RoleScope};
4845    // The finder tests the FROZEN founding mask, never ADMIN_ALL: published
4846    // Admin roles predate later bits (PIN_MESSAGES...), and requiring a bit
4847    // they can't have would orphan every one of them and mint a duplicate.
4848    if let Some(r) = view
4849        .roles
4850        .roles
4851        .iter()
4852        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
4853    {
4854        return Ok(Some(r.role_id.clone()));
4855    }
4856    if !create_if_missing {
4857        return Ok(None);
4858    }
4859    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4860    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
4861    set_role(transport, community, &Role::admin(role_id.clone())).await?;
4862    Ok(Some(role_id))
4863}
4864
4865/// Grant the @admin role (minting it deterministically when absent), MERGED into
4866/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
4867/// blind push would erase their other roles. Owner-only: the position-1 Admin is
4868/// manageable only by position 0 (an equal never outranks it), and refusing
4869/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
4870/// entity from advancing this device's own floor onto a head readers reject.
4871pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4872    crate::db::scoped(async move {
4873        // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
4874        // downstream publish's own (post-swap) guard write account A's floor into B.
4875        let my_pk = me_pk()?;
4876        if my_pk != community.owner()? {
4877            return Err("only the community owner can grant @admin".to_string());
4878        }
4879        let view = fetch_authority(transport, community).await;
4880        let member_hex = member.to_hex();
4881        require_grant_head(community, &view, &member_hex)?;
4882        let role_id = ensure_admin_role(transport, community, &view, true)
4883            .await?
4884            .expect("create_if_missing yields an id");
4885        let mut role_ids = view
4886            .roles
4887            .grants
4888            .iter()
4889            .find(|g| g.member == member_hex)
4890            .map(|g| g.role_ids.clone())
4891            .unwrap_or_default();
4892        if role_ids.contains(&role_id) {
4893            return Ok(()); // already admin — don't bump the grant edition for nothing.
4894        }
4895        role_ids.push(role_id.clone());
4896        // Judge staff-ness against a roster that HOLDS the admin definition: a
4897        // just-minted role hasn't folded into the fetched view yet, and missing it
4898        // would seat an admin without the write key (CORD-04 §3).
4899        let mut roster = view.roles.clone();
4900        if roster.role(&role_id).is_none() {
4901            roster.roles.push(crate::community::roles::Role::admin(role_id));
4902        }
4903        grant_roles_with_roster(transport, community, member, role_ids, &roster).await
4904    })
4905    .await
4906}
4907
4908/// Strip the @admin role from the member's grant, preserving their other roles.
4909/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
4910pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
4911    crate::db::scoped(async move {
4912        let my_pk = me_pk()?;
4913        if my_pk != community.owner()? {
4914            return Err("only the community owner can revoke @admin".to_string());
4915        }
4916        let view = fetch_authority(transport, community).await;
4917        let member_hex = member.to_hex();
4918        require_grant_head(community, &view, &member_hex)?;
4919        let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
4920            return Ok(()); // no admin role exists — nothing to revoke.
4921        };
4922        let mut role_ids = view
4923            .roles
4924            .grants
4925            .iter()
4926            .find(|g| g.member == member_hex)
4927            .map(|g| g.role_ids.clone())
4928            .unwrap_or_default();
4929        let before = role_ids.len();
4930        role_ids.retain(|r| r != &role_id);
4931        if role_ids.len() == before {
4932            return Ok(());
4933        }
4934        // A demotion can still leave the member staff through a KEPT role — the
4935        // fetched view holds every kept definition, so judge against it.
4936        grant_roles_with_roster(transport, community, member, role_ids, &view.roles).await
4937    })
4938    .await
4939}
4940
4941// ── Role authoring (CORD-04 §2/§3) ──────────────────────────────────────────
4942//
4943// Every check below is the rule a READER applies, run before publishing so this
4944// device never signs an edition the fold drops. That matters beyond wasted work:
4945// editions are version-chained, and a dropped head still advances the author's own
4946// floor onto a state nobody honours.
4947
4948/// Who is authoring a role change, and from what standing.
4949struct RoleAuthor {
4950    me_hex: String,
4951    owner_hex: String,
4952    is_owner: bool,
4953    /// The owner is 0; otherwise the highest (lowest-numbered) role position held.
4954    rank: u32,
4955    /// Bits this author may put on a role: everything for the owner, else their own.
4956    grantable: u64,
4957}
4958
4959impl RoleAuthor {
4960    /// Strictly above `position`: equal never acts on equal (CORD-04 §3).
4961    fn outranks(&self, position: u32) -> bool {
4962        self.is_owner || self.rank < position
4963    }
4964    /// A role may gain only bits its author holds. Bits it already has may stay:
4965    /// someone lacking BAN can still rename a role that bans.
4966    fn may_add(&self, from: u64, to: u64) -> bool {
4967        self.is_owner || (to & !from) & !self.grantable == 0
4968    }
4969}
4970
4971fn role_author(community: &CommunityV2, view: &AuthorityView) -> Result<RoleAuthor, String> {
4972    use crate::community::roles::Permissions;
4973    let me = me_pk()?;
4974    let owner = community.owner()?;
4975    let (me_hex, owner_hex) = (me.to_hex(), owner.to_hex());
4976    let is_owner = me == owner;
4977    if !is_owner {
4978        if view.banned.contains(&me_hex) {
4979            return Err("you are banned from this community".to_string());
4980        }
4981        if !view.roles.is_authorized(&me_hex, Some(&owner_hex), Permissions::MANAGE_ROLES) {
4982            return Err("managing roles needs the Manage Roles permission".to_string());
4983        }
4984    }
4985    let rank = if is_owner { 0 } else { view.roles.highest_position(&me_hex).unwrap_or(u32::MAX) };
4986    let grantable = if is_owner { u64::MAX } else { view.roles.effective_permissions(&me_hex).0 };
4987    Ok(RoleAuthor { me_hex, owner_hex, is_owner, rank, grantable })
4988}
4989
4990/// A role's current head must be in hand before replacing it, or this edition could
4991/// chain from a stale version and lose to (or fork from) the one the relays hold.
4992fn require_role_head(view: &AuthorityView, role_id: &str) -> Result<(), String> {
4993    let eid = role_id.to_ascii_lowercase();
4994    if view.floored.contains(&eid) && !view.head_entities.contains(&eid) {
4995        return Err("this role's current state could not be fetched; try again once relays serve the control plane".to_string());
4996    }
4997    Ok(())
4998}
4999
5000/// Mint a Role beneath everything that exists, including its author: a new role
5001/// starts at the bottom, as in Discord, and no role may sit at or above its minter.
5002/// Returns the new `role_id`.
5003pub async fn create_role<T: Transport + ?Sized>(
5004    transport: &T,
5005    community: &CommunityV2,
5006    name: &str,
5007    color: u32,
5008    permissions: u64,
5009    scope: crate::community::roles::RoleScope,
5010) -> Result<String, String> {
5011    crate::db::scoped(async move {
5012        use crate::community::roles::{Permissions, Role};
5013        let view = fetch_authority(transport, community).await;
5014        let author = role_author(community, &view)?;
5015        if view.roles.roles.len() >= super::roles::MAX_ROLES_PER_COMMUNITY {
5016            return Err(format!("a community holds at most {} roles", super::roles::MAX_ROLES_PER_COMMUNITY));
5017        }
5018        if !author.may_add(0, permissions) {
5019            return Err("you can't give a role permissions you don't hold".to_string());
5020        }
5021        let name = name.trim();
5022        if name.is_empty() {
5023            return Err("a role needs a name".to_string());
5024        }
5025        // Channel access roles are parked at the bottom band on purpose (see
5026        // `channel_access_role`), so a new role lands just above that band rather than
5027        // in the one slot left beneath it, where the next would saturate into a peer.
5028        let floor = view
5029            .roles
5030            .roles
5031            .iter()
5032            .map(|r| r.position)
5033            .filter(|p| *p < ACCESS_ROLE_POSITION)
5034            .max()
5035            .unwrap_or(0)
5036            .max(author.rank);
5037        if floor.saturating_add(1) >= ACCESS_ROLE_POSITION {
5038            return Err("there's no room beneath your role for a new one; reorder the roles first".to_string());
5039        }
5040        let role = Role {
5041            role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
5042            name: name.to_string(),
5043            position: floor.saturating_add(1),
5044            permissions: Permissions(permissions),
5045            scope,
5046            color,
5047            extra: Default::default(),
5048        };
5049        set_role(transport, community, &role).await?;
5050        Ok(role.role_id)
5051    })
5052    .await
5053}
5054
5055/// Edit a Role's name, colour and permissions, keeping its position, its scope and
5056/// every field another client wrote. Both CORD-04 §3 gates hold: the author outranks the
5057/// role as it stands, and the edition claims nothing at or above them.
5058pub async fn edit_role<T: Transport + ?Sized>(
5059    transport: &T,
5060    community: &CommunityV2,
5061    role_id: &str,
5062    name: &str,
5063    color: u32,
5064    permissions: u64,
5065    scope: crate::community::roles::RoleScope,
5066) -> Result<(), String> {
5067    crate::db::scoped(async move {
5068        use crate::community::roles::Permissions;
5069        let view = fetch_authority(transport, community).await;
5070        let author = role_author(community, &view)?;
5071        let current = view.roles.role(role_id).cloned().ok_or("no such role")?;
5072        require_role_head(&view, role_id)?;
5073        if !author.outranks(current.position) {
5074            return Err("you can only edit roles beneath your own".to_string());
5075        }
5076        if !author.may_add(current.permissions.0, permissions) {
5077            return Err("you can't give a role permissions you don't hold".to_string());
5078        }
5079        let name = name.trim();
5080        if name.is_empty() {
5081            return Err("a role needs a name".to_string());
5082        }
5083        // A scope is a channel's access list: moving it would re-entitle every holder at
5084        // once, and keys follow grants, not role edits. It is fixed at creation.
5085        if scope != current.scope {
5086            return Err("a role's channel can't be changed once it's created".to_string());
5087        }
5088        let mut next = current.clone();
5089        next.name = name.to_string();
5090        next.color = color;
5091        next.permissions = Permissions(permissions);
5092        if next == current {
5093            return Ok(());
5094        }
5095        set_role(transport, community, &next).await
5096    })
5097    .await
5098}
5099
5100/// Reorder the roles beneath the author, top to bottom as `ordered` lists them.
5101///
5102/// Positions are the whole of CORD-04's ordering (no RoleOrder entity), so a reorder
5103/// republishes each role whose position moves, and nothing else. The author's own
5104/// rank and everything above it stay put: the moved set is renumbered from just
5105/// beneath them, which also resolves any ties into one definite order. Returns how
5106/// many roles were republished.
5107pub async fn reorder_roles<T: Transport + ?Sized>(
5108    transport: &T,
5109    community: &CommunityV2,
5110    ordered: &[String],
5111) -> Result<usize, String> {
5112    crate::db::scoped(async move {
5113        let view = fetch_authority(transport, community).await;
5114        let author = role_author(community, &view)?;
5115        let mut movable: Vec<crate::community::roles::Role> = view
5116            .roles
5117            .roles
5118            .iter()
5119            .filter(|r| author.outranks(r.position))
5120            .cloned()
5121            .collect();
5122        // The requested order first; anything it omits keeps its current relative place.
5123        movable.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.role_id.cmp(&b.role_id)));
5124        let rank_of = |id: &str| ordered.iter().position(|o| o.eq_ignore_ascii_case(id));
5125        let mut keyed: Vec<(usize, usize, crate::community::roles::Role)> = movable
5126            .into_iter()
5127            .enumerate()
5128            .map(|(i, r)| (rank_of(&r.role_id).unwrap_or(usize::MAX), i, r))
5129            .collect();
5130        keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
5131        let mut changed = Vec::new();
5132        for (i, (_, _, role)) in keyed.into_iter().enumerate() {
5133            let position = author.rank.saturating_add(1).saturating_add(i as u32);
5134            if role.position != position {
5135                require_role_head(&view, &role.role_id)?;
5136                let mut next = role;
5137                next.position = position;
5138                changed.push(next);
5139            }
5140        }
5141        for role in &changed {
5142            set_role(transport, community, role).await?;
5143        }
5144        Ok(changed.len())
5145    })
5146    .await
5147}
5148
5149/// Set exactly which Roles `member` holds. A Grant replaces the member's whole set
5150/// (CORD-04 §2), so this is the one place a set is written: every change is judged
5151/// against the author's rank, and ids this device can't resolve ride through
5152/// untouched rather than being stripped by the device that knows least.
5153pub async fn set_member_roles<T: Transport + ?Sized>(
5154    transport: &T,
5155    community: &CommunityV2,
5156    member: &PublicKey,
5157    role_ids: Vec<String>,
5158) -> Result<(), String> {
5159    crate::db::scoped(async move {
5160        use crate::community::roles::Permissions;
5161        let view = fetch_authority(transport, community).await;
5162        let author = role_author(community, &view)?;
5163        let member_hex = member.to_hex();
5164        if member_hex == author.owner_hex {
5165            return Err("the owner's standing isn't granted by roles".to_string());
5166        }
5167        require_grant_head(community, &view, &member_hex)?;
5168        if !author.is_owner && !view.roles.can_act_on_member(&author.me_hex, Some(&author.owner_hex), &member_hex, Permissions::MANAGE_ROLES) {
5169            return Err("you can only change the roles of members beneath you".to_string());
5170        }
5171        let current: Vec<String> = view
5172            .roles
5173            .grants
5174            .iter()
5175            .find(|g| g.member == member_hex)
5176            .map(|g| g.role_ids.clone())
5177            .unwrap_or_default();
5178        let mut next: Vec<String> = Vec::new();
5179        for id in role_ids.iter().map(|r| r.to_ascii_lowercase()) {
5180            if !next.contains(&id) {
5181                next.push(id);
5182            }
5183        }
5184        for id in next.iter().filter(|id| !current.contains(id)) {
5185            let role = view.roles.role(id).ok_or("no such role")?;
5186            if !author.outranks(role.position) {
5187                return Err(format!("you can't grant \"{}\": it isn't beneath your own role", role.name));
5188            }
5189        }
5190        let dropped: Vec<String> = current.iter().filter(|id| !next.contains(id)).cloned().collect();
5191        for id in dropped {
5192            // An unresolvable id can't be judged, so it can't be removed either.
5193            match view.roles.role(&id) {
5194                Some(role) if !author.outranks(role.position) => {
5195                    return Err(format!("you can't remove \"{}\": it isn't beneath your own role", role.name));
5196                }
5197                Some(_) => {}
5198                None => next.push(id),
5199            }
5200        }
5201        if next.len() > super::roles::MAX_ROLES_PER_MEMBER {
5202            return Err(format!("a member holds at most {} roles", super::roles::MAX_ROLES_PER_MEMBER));
5203        }
5204        let mut a = current.clone();
5205        let mut b = next.clone();
5206        a.sort();
5207        b.sort();
5208        if a == b {
5209            return Ok(());
5210        }
5211        grant_roles_with_roster(transport, community, member, next.clone(), &view.roles).await?;
5212        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5213        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member_hex.clone(), role_ids: next.clone() }));
5214        settle_channel_keys(transport, community, &view, member, &current, &next).await
5215    })
5216    .await
5217}
5218
5219/// Move Private Channel keys to match a role change (CORD-03: a key is "delivered on
5220/// grant and rekeyed on removal"). A role scoped to a channel IS its access list, so
5221/// gaining one owes the member that channel's key, and losing the last one that
5222/// entitled them rotates the channel without them. Without this a Grant would move
5223/// entitlement while the keys stayed where they were.
5224async fn settle_channel_keys<T: Transport + ?Sized>(
5225    transport: &T,
5226    community: &CommunityV2,
5227    view: &AuthorityView,
5228    member: &PublicKey,
5229    before: &[String],
5230    after: &[String],
5231) -> Result<(), String> {
5232    use crate::community::roles::RoleScope;
5233    let added: Vec<String> = after.iter().filter(|id| !before.contains(id)).cloned().collect();
5234    let removed: Vec<String> = before.iter().filter(|id| !after.contains(id)).cloned().collect();
5235    let owner_hex = community.owner()?.to_hex();
5236    let member_hex = member.to_hex();
5237    let channel_of = |id: &String| match view.roles.role(id).map(|r| &r.scope) {
5238        Some(RoleScope::Channel(c)) => Some(c.to_ascii_lowercase()),
5239        _ => None,
5240    };
5241    let held = |hex: &str| {
5242        crate::simd::hex::hex_to_bytes_32_checked(hex)
5243            .and_then(|b| community.channel(&ChannelId(b)).map(|c| (c.private, c.key.is_some(), ChannelId(b))))
5244    };
5245
5246    // One Direct Invite carries every channel the new set entitles them to.
5247    if added.iter().filter_map(channel_of).any(|c| matches!(held(&c), Some((true, true, _)))) {
5248        let my_pk = me_pk()?;
5249        let bundle = bundle_of_with_overlay(community, BundleAudience::Member(*member), Some(my_pk), None, None, &added, &removed);
5250        let signer = crate::signer::active_signer()?;
5251        let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
5252        transport.publish(&wrap, &community.relays).await?;
5253    }
5254
5255    let mut severed: Vec<String> = Vec::new();
5256    for chan in removed.iter().filter_map(channel_of) {
5257        if severed.contains(&chan) {
5258            continue;
5259        }
5260        severed.push(chan.clone());
5261        let Some((true, true, channel_id)) = held(&chan) else { continue };
5262        if view.roles.is_entitled(Some(&owner_hex), &member_hex, &chan, &added, &removed) {
5263            continue; // another role they keep still opens it
5264        }
5265        let access_ids = view.roles.channel_role_ids(&chan);
5266        rekey_channel_excluding(transport, community, &channel_id, &view.roles, &access_ids, std::slice::from_ref(member)).await?;
5267    }
5268    Ok(())
5269}
5270
5271/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
5272/// locally but no head folded (withheld / evicted): a blind push at that point
5273/// would erase their other roles at a higher version.
5274fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
5275    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
5276        return Err("malformed member key".to_string());
5277    };
5278    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
5279    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
5280        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
5281    }
5282    Ok(())
5283}
5284
5285/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
5286/// whole list on every edit. Gated on the reader side by `BAN` per head plus strict
5287/// outrank per entry — mirrored here BEFORE the publish, so an unauthorized caller
5288/// (an SDK bot without the bit, a demoted moderator) fails loudly instead of
5289/// publishing an edition every reader silently rejects while its own local echo
5290/// caches the phantom ban.
5291pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
5292    crate::db::scoped(async move {
5293        super::roles::validate_banlist(banned)?;
5294        {
5295            let my_pk = me_pk()?;
5296            let owner = community.owner()?;
5297            if my_pk != owner {
5298                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5299                let me_hex = my_pk.to_hex();
5300                let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5301                if current.contains(&me_hex) {
5302                    return Err("you are banned from this community".to_string());
5303                }
5304                let roster = crate::db::community::get_community_roles(&cid_hex)?;
5305                let owner_hex = owner.to_hex();
5306                if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5307                    return Err("editing the banlist needs the BAN permission".to_string());
5308                }
5309                // Only the entries this edit ADDS need the per-target outrank (the fold
5310                // keeps prior authorized bans alive through its per-candidate history).
5311                for target in banned.iter().filter(|t| !current.contains(*t)) {
5312                    if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
5313                        return Err("you do not outrank a member this ban targets".to_string());
5314                    }
5315                }
5316            }
5317        }
5318        let content = super::roles::banlist_content_json(banned)?;
5319        let eid = super::derive::banlist_locator(community.id());
5320        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5321        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
5322        // The version this publish will chain to — mirrors publish_control_edition.
5323        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
5324            Some((v, _)) => v + 1,
5325            None => 1,
5326        };
5327        publish_control_edition(transport, community, vsk::BANLIST, &eid, &content).await?;
5328        // ECHO the published list into the local cache at once. Without this, the
5329        // cache only moves on a successful control-plane fold — and a caller
5330        // composing ban steps (banlist → grant strip → refound) re-reads the STALE
5331        // list if any later step trips before the fold, so each new banlist
5332        // edition it builds ERASES every ban since the last fold. Nineteen bans in
5333        // production each overwrote their predecessor exactly this way.
5334        let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
5335        Ok(())
5336    })
5337    .await
5338}
5339
5340/// Per-community moderation-unit locks. Session-scoped: an account swap drops the
5341/// map with its session, so no cross-account carry-over and nothing to clear.
5342struct ModerationLocks;
5343fn moderation_lock(cid: &crate::community::CommunityId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
5344    let map = crate::db::current_session()
5345        .scoped::<ModerationLocks, std::sync::Mutex<std::collections::HashMap<[u8; 32], std::sync::Arc<tokio::sync::Mutex<()>>>>>();
5346    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5347    m.entry(cid.0).or_default().clone()
5348}
5349
5350/// Fold control + guestbook so a moderation unit composes over the freshest view
5351/// another moderator may have advanced. Best-effort: on a dead network the local
5352/// cache (kept fresh by `set_banlist`'s own echo) is still the best basis.
5353async fn converge_authority<T: Transport + ?Sized>(transport: &T, id: &crate::community::CommunityId) {
5354    if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
5355        let _ = follow_control(transport, &fresh).await;
5356        // Membership is part of the converged view: a Join that legally raced a ban
5357        // window may exist only on the relays, and our own just-published edition
5358        // doesn't echo back to trigger a follow.
5359        if let Ok(added) = sync_guestbook(transport, &fresh).await {
5360            if !added.is_empty() {
5361                crate::traits::emit_event_json(
5362                    "community_refreshed",
5363                    serde_json::json!({ "community_id": crate::simd::hex::bytes_to_hex_32(&id.0) }),
5364                );
5365            }
5366        }
5367    }
5368}
5369
5370/// One queued ban/unban intent, answered on `done` when its batch completes.
5371struct ModIntent {
5372    id: u64,
5373    targets: Vec<PublicKey>,
5374    banned: bool,
5375    done: tokio::sync::oneshot::Sender<Result<(), String>>,
5376}
5377
5378/// Per-community pending moderation intents — the coalescing queue behind
5379/// [`moderation_lock`]. Session-scoped for the same reason as the lock.
5380struct PendingModeration;
5381fn pending_moderation(cid: &crate::community::CommunityId) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>> {
5382    let _ = cid;
5383    crate::db::current_session()
5384        .scoped::<PendingModeration, std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<ModIntent>>>>()
5385}
5386
5387/// Ban or unban members as ONE moderation unit — the CORD-04 §6 three-removal
5388/// composition, once for the whole wave: one Banlist edition (instant silence),
5389/// one grant-strip pass (authority removal), and one read cut (a private
5390/// community re-founds ONCE with every target removed; a public one rotates each
5391/// reachable private channel ONCE excluding them all).
5392///
5393/// Serialized AND coalesced per community. Without the lock, concurrent bans
5394/// (the SDK spawns a handler task per message, so a spam wave is exactly this)
5395/// each build their edition from a pre-sibling banlist snapshot, and the fold
5396/// takes the HEAD's content — so the last racing head silently un-bans the
5397/// earlier target at every reader, and the refound then compacts that erasure
5398/// into the new epoch permanently.
5399///
5400/// The coalescing half: a unit's read cut holds the lock for a whole multi-
5401/// publish rotation, so serial single bans arriving meanwhile would each queue
5402/// up a rotation of their own. Instead every caller deposits an intent; whoever
5403/// next takes the lock drains ALL of them into one edition + one read cut,
5404/// applied in arrival order (ban-then-unban of one npub nets to unban). Each
5405/// intent is judged individually — a target the caller cannot outrank, or an
5406/// add past the 500-entry ceiling, fails only its own caller, never the batch.
5407pub async fn set_members_banned<T: Transport + ?Sized>(
5408    transport: &T,
5409    community_id: &crate::community::CommunityId,
5410    members: &[PublicKey],
5411    banned: bool,
5412) -> Result<(), String> {
5413    crate::db::scoped(async move {
5414        // Dedup — a doubled target must not strip or rotate twice.
5415        let mut targets: Vec<PublicKey> = Vec::with_capacity(members.len());
5416        for m in members {
5417            if !targets.contains(m) {
5418                targets.push(*m);
5419            }
5420        }
5421        if targets.is_empty() {
5422            return Ok(());
5423        }
5424        // Plain uniqueness source for intent ids — not per-account state.
5425        static INTENT_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
5426        let my_id = INTENT_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5427        let (tx, rx) = tokio::sync::oneshot::channel();
5428        {
5429            let map = pending_moderation(community_id);
5430            let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5431            m.entry(community_id.0).or_default().push(ModIntent { id: my_id, targets, banned, done: tx });
5432        }
5433        let unit = moderation_lock(community_id);
5434        // `biased` so a verdict delivered by another leader wins over a lock we
5435        // happen to also hold — a drained intent must never run twice.
5436        tokio::select! {
5437            biased;
5438            res = rx => res.unwrap_or_else(|_| Err("moderation unit aborted before completing".to_string())),
5439            _guard = unit.lock() => {
5440                // Leader: drain EVERYTHING pending right now. Every ban/unban that
5441                // stacked while the previous unit's rotation ran coalesces here.
5442                let batch: Vec<ModIntent> = {
5443                    let map = pending_moderation(community_id);
5444                    let mut m = map.lock().unwrap_or_else(|e| e.into_inner());
5445                    m.remove(&community_id.0).unwrap_or_default()
5446                };
5447                run_moderation_batch(transport, community_id, batch, my_id).await
5448            }
5449        }
5450    })
5451    .await
5452}
5453
5454/// Execute one drained batch of moderation intents under the held lock: judge
5455/// each intent in arrival order against the evolving list, publish the coalesced
5456/// edition, strip + read-cut the net newly-banned set once, then answer every
5457/// intent with its own verdict. Returns the LEADER's verdict.
5458async fn run_moderation_batch<T: Transport + ?Sized>(
5459    transport: &T,
5460    community_id: &crate::community::CommunityId,
5461    batch: Vec<ModIntent>,
5462    leader_id: u64,
5463) -> Result<(), String> {
5464    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
5465
5466    // SYNC BEFORE COMPOSING: a banlist edition replaces the whole list on the
5467    // wire, so the mutation must start from the freshest view — another
5468    // moderator's ban this device hasn't folded yet would otherwise be
5469    // silently erased by ours.
5470    converge_authority(transport, community_id).await;
5471
5472    let original = crate::db::community::get_community_banlist(&cid_hex)?;
5473    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5474    let community0 = crate::db::community::load_community_v2(community_id)?
5475        .ok_or_else(|| "v2 community not found".to_string())?;
5476    let owner = community0.owner()?;
5477    let my_pk = me_pk()?;
5478    let my_hex = my_pk.to_hex();
5479    let owner_hex = owner.to_hex();
5480
5481    // Judge intents in arrival order against the EVOLVING list, so the last
5482    // intent naming an npub wins — exactly serial semantics, minus the churn.
5483    let mut list = original.clone();
5484    let mut pk_of: std::collections::HashMap<String, PublicKey> = std::collections::HashMap::new();
5485    let mut verdicts: std::collections::HashMap<u64, Result<(), String>> = std::collections::HashMap::new();
5486    for intent in &batch {
5487        let hexes: Vec<String> = intent.targets.iter().map(|p| p.to_hex()).collect();
5488        for (pk, hex) in intent.targets.iter().zip(&hexes) {
5489            pk_of.insert(hex.clone(), *pk);
5490        }
5491        let verdict = (|| {
5492            // Mirror of set_banlist's authority gate, per intent, so one refused
5493            // intent cannot sink its batch-mates.
5494            if my_pk != owner {
5495                if original.contains(&my_hex) {
5496                    return Err("you are banned from this community".to_string());
5497                }
5498                if !roster.is_authorized(&my_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
5499                    return Err("editing the banlist needs the BAN permission".to_string());
5500                }
5501                if intent.banned {
5502                    for t in hexes.iter().filter(|t| !list.contains(*t)) {
5503                        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), t, crate::community::roles::Permissions::BAN) {
5504                            return Err("you do not outrank a member this ban targets".to_string());
5505                        }
5506                    }
5507                }
5508            }
5509            let mut next = list.clone();
5510            next.retain(|h| !hexes.contains(h));
5511            if intent.banned {
5512                next.extend(hexes.iter().cloned());
5513            }
5514            super::roles::validate_banlist(&next)?;
5515            list = next;
5516            Ok(())
5517        })();
5518        verdicts.insert(intent.id, verdict);
5519    }
5520
5521    let changed = {
5522        let mut a = original.clone();
5523        let mut b = list.clone();
5524        a.sort();
5525        b.sort();
5526        a != b
5527    };
5528    let newly_banned: Vec<PublicKey> = list
5529        .iter()
5530        .filter(|h| !original.contains(*h))
5531        .filter_map(|h| pk_of.get(h).copied())
5532        .collect();
5533
5534    // Publish the coalesced edition + strips, then the single read cut.
5535    let unit_result: Result<(), String> = async {
5536        if !changed {
5537            return Ok(());
5538        }
5539        // Rotation barrier: a sibling refound holds this lock for its whole
5540        // multi-publish rotation while the row still names the OLD root. Dropped
5541        // before `refound_community`, which re-acquires it (non-reentrant).
5542        let (community, stripped) = {
5543            let lock = super::realtime::follow_lock(community_id);
5544            let _rotation = lock.lock().await;
5545            let community = crate::db::community::load_community_v2(community_id)?
5546                .ok_or_else(|| "v2 community not found".to_string())?;
5547            set_banlist(transport, &community, &list).await?;
5548            let mut stripped: Vec<(PublicKey, Vec<String>)> = Vec::new();
5549            // Pre-strip role capture: the read-severance below judges which
5550            // private channels each member could reach, and after the strip
5551            // the fold may no longer show it.
5552            for pk in &newly_banned {
5553                let hex = pk.to_hex();
5554                let roles: Vec<String> = roster.roles_of(&hex).map(|r| r.role_id.clone()).collect();
5555                // Nothing granted = nothing to strip: a bulk ban of roleless
5556                // spammers must not publish one empty Grant edition apiece.
5557                let has_grant = roster.grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
5558                if has_grant {
5559                    // Strip on the kick doctrine (skip, not refuse): readers honor a
5560                    // grant strip only from MANAGE_ROLES + outrank, and a BAN-only
5561                    // moderator still bans — the banlist silences regardless, and
5562                    // publishing a strip readers reject would only poison our own
5563                    // floor for that grant entity.
5564                    let can_strip = my_pk == owner
5565                        || roster.can_act_on_member(&my_hex, Some(&owner_hex), &hex, crate::community::roles::Permissions::MANAGE_ROLES);
5566                    if can_strip {
5567                        grant_roles(transport, &community, pk, vec![]).await?;
5568                    } else {
5569                        crate::log_warn!("[Ban] grant strip skipped (no MANAGE_ROLES over the target); the banlist still silences");
5570                    }
5571                }
5572                stripped.push((*pk, roles));
5573            }
5574            (community, stripped)
5575        };
5576        if !newly_banned.is_empty() {
5577            // CORD-05 §5 gate: a PUBLIC community — any live invite link — must NOT
5578            // refound on ban. The link refresh re-posts the bundle with the new root
5579            // behind the same URL, so the banned member re-fetches and reads on: the
5580            // rotation severs nothing and can strand foreign-link joiners on a buried
5581            // epoch. The Banlist does all the real work in Public mode.
5582            if community_is_public(transport, &community).await {
5583                crate::log_info!("[Ban] public community — banlist + grant strip, no refound (CORD-05 §5)");
5584                // CORD-06 §1 still applies per channel: only rotation severs the
5585                // private-channel reads their held keys still allow.
5586                match sever_banned_private_reads(transport, &community, &stripped).await {
5587                    Ok(0) => {}
5588                    Ok(n) => crate::log_info!("[Ban] rotated {n} private channel(s) the banned member(s) could read"),
5589                    Err(e) => crate::log_warn!("[Ban] private-channel read severance incomplete: {e}"),
5590                }
5591            } else if let Err(e) = refound_community(transport, &community, &newly_banned).await {
5592                // Best-effort escalation, NEVER the ban's verdict. The banlist
5593                // edition (instant silence at every honest reader) and the grant
5594                // strip ARE the ban; the refound's §3 coverage gate demands the
5595                // relay serve back heads published milliseconds ago, so propagation
5596                // lag makes it fail-closed — and propagating that error out of here
5597                // once left every ban half-applied AND skipped the converge below,
5598                // so each next ban rebuilt from a stale list and erased its
5599                // predecessors.
5600                crate::log_warn!("[Ban] refound deferred (banlist + grant strip landed): {e}");
5601            }
5602        }
5603        Ok(())
5604    }
5605    .await;
5606    converge_authority(transport, community_id).await;
5607
5608    // Answer every intent: its own judgment first, then the shared publish fate.
5609    let mut my_verdict = Err("leader intent missing from its own batch".to_string());
5610    for intent in batch {
5611        let final_verdict = verdicts
5612            .remove(&intent.id)
5613            .unwrap_or_else(|| Err("intent was never judged".to_string()))
5614            .and_then(|()| unit_result.clone());
5615        if intent.id == leader_id {
5616            my_verdict = final_verdict;
5617        } else {
5618            let _ = intent.done.send(final_verdict);
5619        }
5620    }
5621    my_verdict
5622}
5623
5624// ── Raid containment (CORD-06 Severing Refounding) ──────────────────────────
5625
5626/// What a containment actually accomplished. Every field is an honest count —
5627/// a caller (Sentinel, a moderation console) reports these to a human, so a
5628/// half-done containment must never read as a closed door.
5629#[derive(Debug, Clone, Default, serde::Serialize)]
5630pub struct ContainmentReport {
5631    /// Raiders added to the Banlist.
5632    pub banned: usize,
5633    /// Members cut for joining inside the raid window without being convicted.
5634    pub window_cut: usize,
5635    /// Root epoch before and after. Equal = the refound did NOT land.
5636    pub epoch_before: u64,
5637    pub epoch_after: u64,
5638    /// True only if the severing refound published.
5639    pub refound_ok: bool,
5640    /// My own public links revoked here. Other creators' links die as their
5641    /// clients adopt the severed rotation.
5642    pub own_links_revoked: usize,
5643    /// Creators whose links are still live pending their client's adoption.
5644    pub foreign_link_creators: Vec<String>,
5645    /// Non-fatal problems worth showing a human.
5646    pub warnings: Vec<String>,
5647}
5648
5649/// Contain a raid: ban the raiders, cut everyone who walked in through the door
5650/// during the raid window, then perform a SEVERING Refounding — the rotation
5651/// that revokes the invite links instead of carrying them into the new epoch.
5652///
5653/// This is the raid lane, not a general ban. An ordinary ban (even in a Public
5654/// community) keeps its shipped behaviour — [`set_members_banned`] decides
5655/// internally, and a public one deliberately does NOT roll the root. Here the
5656/// door itself is the vector, so it closes with the rotation.
5657///
5658/// `window_start_secs` is the raid's opening edge: unconvicted members who
5659/// joined at-or-after it are cut from the new epoch (NOT banned — an innocent
5660/// caught in the window rejoins through the owner's next link). Members already
5661/// present before the window are never cut, however quiet.
5662pub async fn contain_raid<T: Transport + ?Sized>(
5663    transport: &T,
5664    community_id: &crate::community::CommunityId,
5665    raiders: &[PublicKey],
5666    window_start_secs: u64,
5667    cut_also: &[PublicKey],
5668) -> Result<ContainmentReport, String> {
5669    crate::db::scoped(async move {
5670        let mut report = ContainmentReport::default();
5671        let community = crate::db::community::load_community_v2(community_id)?
5672            .ok_or_else(|| "v2 community not found".to_string())?;
5673        report.epoch_before = community.root_epoch.0;
5674        report.epoch_after = community.root_epoch.0;
5675        let owner = community.owner()?;
5676        let my_pk = me_pk()?;
5677
5678        // 1. Ban the convicted. Goes through the coalescer: the banlist edition is
5679        //    a read-modify-write, and a concurrent single ban would otherwise
5680        //    rebuild from a stale list and erase this one. A FAILED ban (most
5681        //    plausibly the 500-entry banlist ceiling under a mega-raid) must not
5682        //    abort the containment: the refound below cuts the raiders
5683        //    cryptographically whether or not the list could name them.
5684        if !raiders.is_empty() {
5685            match set_members_banned(transport, community_id, raiders, true).await {
5686                Ok(()) => report.banned = raiders.len(),
5687                Err(e) => report.warnings.push(format!("banlist did not land ({e}) — the refound still cuts every named raider")),
5688            }
5689        }
5690
5691        // Re-read: the ban published a Banlist edition and stripped grants.
5692        let community = crate::db::community::load_community_v2(community_id)?
5693            .ok_or_else(|| "v2 community not found".to_string())?;
5694
5695        // 2. The window cut. A raid arrives THROUGH the link, so everyone who
5696        //    walked in during it is part of the wave for containment purposes —
5697        //    conviction only ever names the ones who spoke. Never the owner, never
5698        //    staff, never anyone whose earliest trace predates the window.
5699        //
5700        //    `cut_also` first: Join timestamps are AUTHOR-claimed, so a quiet
5701        //    raider who backdated their Join slides under any window this fold
5702        //    can compute. A caller watching arrivals on its own clock (Sentinel's
5703        //    receive log) names them here and they are cut regardless.
5704        let mut cut: Vec<PublicKey> = raiders.to_vec();
5705        for pk in cut_also {
5706            if !cut.contains(pk) {
5707                cut.push(*pk);
5708                report.window_cut += 1;
5709            }
5710        }
5711        if window_start_secs > 0 {
5712            let roster = crate::db::community::get_community_roles(
5713                &crate::simd::hex::bytes_to_hex_32(&community.id().0),
5714            )
5715            .unwrap_or_default();
5716            let owner_hex = owner.to_hex();
5717            match fetch_guestbook_events(transport, &community, 0).await {
5718                Ok((events, _, _)) => {
5719                    // EARLIEST trace per member: a re-announced Join must never make
5720                    // an established member read as a window joiner.
5721                    let mut first_seen: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
5722                    for ev in &events {
5723                        if let guestbook::GuestbookEntry::Join { member, at_ms, .. } = &ev.entry {
5724                            let secs = at_ms / 1000;
5725                            first_seen
5726                                .entry(*member)
5727                                .and_modify(|e| *e = (*e).min(secs))
5728                                .or_insert(secs);
5729                        }
5730                    }
5731                    for (pk, joined) in first_seen {
5732                        if joined < window_start_secs || pk == owner || pk == my_pk || cut.contains(&pk) {
5733                            continue;
5734                        }
5735                        // Staff are never cut by a window: a moderator who joined
5736                        // moments before a raid is still a moderator.
5737                        if roster.is_staff(&pk.to_hex(), Some(&owner_hex)) {
5738                            continue;
5739                        }
5740                        cut.push(pk);
5741                        report.window_cut += 1;
5742                    }
5743                }
5744                Err(e) => report
5745                    .warnings
5746                    .push(format!("guestbook unreadable — no window cut applied, only convicted raiders are severed ({e})")),
5747            }
5748        }
5749
5750        // 3. The severing refound. Private channels are deliberately NOT rotated
5751        //    separately: the root roll re-keys every plane, and link-borne raiders
5752        //    were never entitled to a private channel in the first place.
5753        //
5754        //    Under the MODERATION lock, exactly as `run_moderation_batch` holds it
5755        //    across ITS refound: an ordinary ban racing this rotation would publish
5756        //    a banlist edition the compaction fetch below has already read past,
5757        //    and the new epoch would carry it away — a ban silently erased at the
5758        //    moment bans matter most. Lock order (moderation → follow) matches the
5759        //    shipped path. Retried: the refound's coverage gate fails closed under
5760        //    exactly the relay lag a mid-raid flood produces.
5761        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5762        let links_before = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5763        let unit = moderation_lock(community_id);
5764        let _guard = unit.lock().await;
5765        let mut refound = Err("severing refound never attempted".to_string());
5766        for attempt in 0..3u8 {
5767            refound = refound_community_severing(transport, &community, &cut, window_start_secs).await;
5768            if refound.is_ok() {
5769                break;
5770            }
5771            if attempt < 2 {
5772                crate::rt::time::sleep(std::time::Duration::from_millis(400 * (attempt as u64 + 1))).await;
5773            }
5774        }
5775        match refound {
5776            Ok(updated) => {
5777                report.refound_ok = true;
5778                report.epoch_after = updated.root_epoch.0;
5779                let links_after = crate::db::community::list_public_invites(&cid_hex).map(|l| l.len()).unwrap_or(0);
5780                report.own_links_revoked = links_before.saturating_sub(links_after);
5781                if links_after > 0 {
5782                    report.warnings.push(format!(
5783                        "{links_after} of my own link(s) survived the sever — the mark retries them on the next follow"
5784                    ));
5785                }
5786                report.foreign_link_creators = crate::db::community::invite_link_set_creators(&cid_hex)
5787                    .unwrap_or_default()
5788                    .into_iter()
5789                    .filter(|c| *c != my_pk.to_hex())
5790                    .collect();
5791            }
5792            Err(e) => {
5793                // The banlist landed and silences the named raiders at every honest
5794                // reader, but the DOOR IS STILL OPEN. Never dress this as success.
5795                report.warnings.push(format!("SEVERING REFOUND FAILED — the invite door may still be open: {e}"));
5796            }
5797        }
5798        Ok(report)
5799    })
5800    .await
5801}
5802
5803/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
5804/// `MANAGE_METADATA`.
5805pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
5806    ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
5807    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
5808    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5809    publish_control_edition(transport, community, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
5810}
5811
5812/// Persist a freshly-published icon/banner onto the held row and return the fresh
5813/// row. Reloads under the community's follow lock: `save_community_v2` is a
5814/// whole-row save that prunes channels absent from the passed struct, so writing
5815/// a stale pre-upload copy would drop rows a concurrent fold just landed.
5816pub async fn persist_community_image(
5817    id: &crate::community::CommunityId,
5818    img: control::ImageRef,
5819    is_banner: bool,
5820) -> Option<CommunityV2> {
5821    let lock = super::realtime::follow_lock(id);
5822    let _guard = lock.lock().await;
5823    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
5824    if is_banner {
5825        fresh.banner = Some(img);
5826    } else {
5827        fresh.icon = Some(img);
5828    }
5829    crate::db::community::save_community_v2(&fresh).ok()?;
5830    Some(fresh)
5831}
5832
5833/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
5834/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
5835pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
5836    let my_pk = me_pk()?;
5837    ensure_channel_manager(community, &my_pk)?;
5838    let old_name = community.channel(channel_id).map(|c| c.name.clone());
5839    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
5840    // mint yet — refuse the flag flip rather than publish an edition no reader can
5841    // key (members would keep posting on the root-derived plane, splitting the
5842    // channel). Private → public works (readers heal to the root derivation).
5843    if meta.private {
5844        if let Some(held) = community.channel(channel_id) {
5845            if !held.private {
5846                return Err("converting a public channel to private is not supported yet".to_string());
5847            }
5848        }
5849    }
5850    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
5851    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
5852    publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
5853    // Apply locally too. The fold is the authority but runs later, so without this
5854    // an edit we just made reads back stale until some future control pass — the
5855    // rename appears to have silently failed.
5856    if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
5857        if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
5858            ch.name = meta.name.clone();
5859            ch.private = meta.private;
5860            ch.voice = meta.voice;
5861            ch.meta_custom = meta.custom.clone();
5862            ch.meta_extra = meta.extra.clone();
5863            crate::db::community::save_community_v2(&held)?;
5864        }
5865    }
5866    // Keep the companion access role's label in step with the channel it gates.
5867    if meta.private {
5868        if let Some(old) = old_name.filter(|o| *o != meta.name) {
5869            rename_channel_access_role(transport, community, channel_id, &old, &meta.name).await;
5870        }
5871    }
5872    Ok(())
5873}
5874
5875/// Rename a private channel's companion access role to follow the channel (CORD-04 §2).
5876/// Best-effort and never fatal: the channel rename has already published, and a role's
5877/// name is cosmetic — entitlement is carried by the scope, not the label.
5878///
5879/// Only renames a label still equal to the channel's OLD name, so a deliberately
5880/// customised role name survives a channel rename untouched.
5881async fn rename_channel_access_role<T: Transport + ?Sized>(
5882    transport: &T,
5883    community: &CommunityV2,
5884    channel_id: &ChannelId,
5885    old_name: &str,
5886    new_name: &str,
5887) {
5888    crate::db::scoped(async move {
5889        let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
5890            return;
5891        };
5892        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5893        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5894        // Fetched, not cached: `set_role` republishes the WHOLE role body, so a stale
5895        // cache would clobber a permission edit this client has not folded yet.
5896        let mut roster = fetch_authority(transport, community).await.roles;
5897        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5898        for r in cached.roles {
5899            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
5900                roster.roles.push(r);
5901            }
5902        }
5903        // MANAGE_CHANNELS got us the rename; the role edition needs MANAGE_ROLES + outrank
5904        // of its own. Publishing one readers reject would wedge our later, legitimate role
5905        // edits behind a rejected chain, so verify before publishing rather than after.
5906        let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
5907        if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
5908            return;
5909        }
5910        // Same selector `grant_channel_access` vends: the permission-less scoped role. A
5911        // per-channel moderator role sharing the scope is NOT the access list.
5912        let Some(mut role) = roster
5913            .channel_roles(&chan_hex)
5914            .into_iter()
5915            .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
5916            .cloned()
5917        else {
5918            return;
5919        };
5920        if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
5921            return;
5922        }
5923        role.name = new_name.to_string();
5924        if let Err(e) = set_role(transport, community, &role).await {
5925            crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
5926            return;
5927        }
5928        merge_local_roster(&cid_hex, Some(&role), None);
5929    })
5930    .await
5931}
5932
5933/// The local mirror of a reader-side permission fold gate: the owner, or a
5934/// roster-authorized holder of `needed` who isn't banned. Refusing BEFORE any
5935/// publish keeps an unauthorized device from advancing its own edition floor onto
5936/// a head every reader rejects (wedging its later, legitimately-authorized edits
5937/// behind a rejected chain). Fail-closed: an empty/unfolded roster collapses to
5938/// owner-only — it can over-restrict, never grant authority no one has.
5939fn ensure_folded_permission(
5940    community: &CommunityV2,
5941    me: &PublicKey,
5942    needed: u64,
5943    verb: &str,
5944) -> Result<(), String> {
5945    let owner = community.owner()?;
5946    if *me == owner {
5947        return Ok(());
5948    }
5949    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5950    let me_hex = me.to_hex();
5951    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
5952        return Err("you are banned from this community".to_string());
5953    }
5954    let roster = crate::db::community::get_community_roles(&cid_hex)?;
5955    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
5956        Ok(())
5957    } else {
5958        Err(format!("{verb} needs the {} permission", permission_label(needed)))
5959    }
5960}
5961
5962fn permission_label(bits: u64) -> &'static str {
5963    use crate::community::roles::Permissions;
5964    match bits {
5965        Permissions::MANAGE_ROLES => "MANAGE_ROLES",
5966        Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
5967        Permissions::MANAGE_METADATA => "MANAGE_METADATA",
5968        Permissions::BAN => "BAN",
5969        Permissions::CREATE_INVITE => "CREATE_INVITE",
5970        _ => "required",
5971    }
5972}
5973
5974/// [`ensure_folded_permission`] for `MANAGE_CHANNELS` (CORD-03 §2).
5975fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
5976    ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
5977}
5978
5979/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
5980/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
5981/// Plane from the `community_root` (no per-channel key), so other members fold it in on
5982/// their next control follow with nothing to distribute. Returns the new channel id.
5983/// Reader-gated by `MANAGE_CHANNELS`.
5984pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
5985    let channel_id = ChannelId(super::super::random_32());
5986    create_public_channel_with_id(transport, community, name, channel_id).await?;
5987    Ok(channel_id)
5988}
5989
5990/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
5991/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
5992/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
5993pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
5994    crate::db::scoped(async move {
5995        // Serialize with the follow worker: the save below writes the WHOLE community
5996        // row from this caller's struct, so an unserialized concurrent follow adopting
5997        // a rotation would be rolled back to a stale root (a deaf community).
5998        let lock = super::realtime::follow_lock(community.id());
5999        let _guard = lock.lock().await;
6000        let my_pk = me_pk()?;
6001        ensure_channel_manager(community, &my_pk)?;
6002        assert_channel_id_free(&channel_id, community.id())?;
6003        let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6004        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
6005        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6006        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6007        // Add locally + persist so the creator can post immediately (peers fold it in).
6008        let mut updated = community.clone();
6009        updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6010        crate::db::community::save_community_v2(&updated)?;
6011        Ok(())
6012    })
6013    .await
6014}
6015
6016/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
6017/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
6018/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
6019/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
6020fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
6021    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6022    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
6023        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
6024        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
6025        if existing != mine
6026            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
6027        {
6028            return Err("channel id is already live in another v2 community".to_string());
6029        }
6030    }
6031    Ok(())
6032}
6033
6034/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
6035/// random key at channel-epoch 1, mint a companion channel-scoped Role that is
6036/// the channel's access list (CORD-04 §2), deliver the key to the entitled over
6037/// the rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`).
6038/// Epoch 0 is the root generation ("the first privatisation is epoch 1"), so the
6039/// delivery commits its continuity to `(0, community_root)` — verifiable by every
6040/// member and bound to THIS community's root. The key ships BEFORE the
6041/// announcement: an aborted attempt leaves only an unannounced crate (invisible),
6042/// and a retry mints a fresh id, so there is no same-coordinate double-mint to
6043/// fork on. Live public links are refreshed; they carry no private key, so this
6044/// only re-states the public set.
6045pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
6046    let channel_id = ChannelId(super::super::random_32());
6047    create_private_channel_with_id(transport, community, name, channel_id).await?;
6048    Ok(channel_id)
6049}
6050
6051/// The companion Role minted alongside a Private channel — the channel's access
6052/// list (CORD-04 §2 `scope: {"kind":"channel"}`). Same name as the channel, and
6053/// **no permission bits**: it confers read access, which is key possession, never
6054/// authority. Position sits below every management role for the same reason.
6055/// Where channel access roles sit: the bottom, so anyone who can manage roles outranks
6056/// them and may hand out read access.
6057pub const ACCESS_ROLE_POSITION: u32 = u32::MAX - 1;
6058
6059pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
6060    use crate::community::roles::{Permissions, Role, RoleScope};
6061    Role {
6062        role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
6063        name: name.to_string(),
6064        position: ACCESS_ROLE_POSITION,
6065        permissions: Permissions::empty(),
6066        scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
6067        color: 0, extra: Default::default(),
6068    }
6069}
6070
6071/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
6072/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
6073pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
6074    crate::db::scoped(async move {
6075        // Serialize with the follow worker across the whole fetch→publish→save span
6076        // (the memberlist fetch is seconds long; an unserialized follow adopting a
6077        // rotation meanwhile would be rolled back by the whole-row save below).
6078        let lock = super::realtime::follow_lock(community.id());
6079        let _guard = lock.lock().await;
6080        let signer = crate::signer::active_signer()?;
6081        let my_pk = me_pk()?;
6082        ensure_channel_manager(community, &my_pk)?;
6083        assert_channel_id_free(&channel_id, community.id())?;
6084        let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6085        control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
6086        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6087
6088        let channel_key = super::super::random_32();
6089        let epoch = Epoch(1);
6090
6091        // The channel's access list: a companion channel-scoped Role (CORD-04 §2),
6092        // granted to me so the creator is entitled from the first edition.
6093        let access_role = channel_access_role(&channel_id, name);
6094        let access_role_ids = vec![access_role.role_id.clone()];
6095
6096        // Recipients are the ENTITLED, not the memberlist: CORD-03's private channel
6097        // is "readable only by granted role-holders". At create that is me (plus the
6098        // owner, who is always entitled) — everyone else keys up when granted.
6099        let owner = community.owner()?;
6100        let mut recipients = vec![my_pk];
6101        if owner != my_pk {
6102            recipients.push(owner);
6103        }
6104        let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6105        let mut blobs = Vec::with_capacity(recipients.len());
6106        for r in &recipients {
6107            blobs.push(
6108                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
6109                    .await
6110                    .map_err(|e| e.to_string())?,
6111            );
6112        }
6113        let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
6114        let at_secs = now_ms() / 1000;
6115        let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6116            .await
6117            .map_err(|e| e.to_string())?;
6118        for c in &chunks {
6119            transport.publish_durable(c, &community.relays).await?;
6120        }
6121        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6122        // Publish the access list AFTER the channel exists, so a peer folding the
6123        // Role always resolves the channel it scopes to. A failure here leaves a
6124        // channel only its creator can read — recoverable by re-granting, never a
6125        // leak.
6126        set_role(transport, community, &access_role).await?;
6127        // Judge staff-ness against a roster that HOLDS the role just minted above —
6128        // no fold has read it back yet, and the access role is permission-less, so
6129        // resolving it locally both skips a round trip and gets the right answer.
6130        let mut roster_with_access = crate::db::community::get_community_roles(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap_or_default();
6131        if roster_with_access.role(&access_role.role_id).is_none() {
6132            roster_with_access.roles.push(access_role.clone());
6133        }
6134        grant_roles_with_roster(transport, community, &my_pk, access_role_ids.clone(), &roster_with_access).await?;
6135        // The fold is the authority but runs later; without this the creator is not
6136        // yet entitled to their own channel and the next grant finds no access role.
6137        merge_local_roster(
6138            &crate::simd::hex::bytes_to_hex_32(&community.id().0),
6139            Some(&access_role),
6140            Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
6141        );
6142        // A leave/delete raced the create: saving would resurrect the community row.
6143        if crate::db::community::community_protocol(community.id())?.is_none() {
6144            return Err("community removed during channel create".to_string());
6145        }
6146        let mut updated = community.clone();
6147        updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6148        crate::db::community::save_community_v2(&updated)?;
6149        // Archive the epoch-1 key so this channel's history stays readable across its
6150        // future rotations (CORD-03 §3).
6151        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6152        crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
6153        // Re-state live links. They carry no private key (CORD-05 §2 — a link's
6154        // audience holds no Role), so this only refreshes the public set.
6155        let _ = refresh_public_links(transport, &updated).await;
6156        Ok(())
6157    })
6158    .await
6159}
6160
6161// ── Receiving a key vend (CORD-03 "delivered on grant") ──────────────────────
6162
6163/// What a client should do with a vended Private-Channel key right now.
6164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6165pub enum VendVerdict {
6166    /// Every rule passed — adopt the key.
6167    Accept,
6168    /// Cannot judge YET: our fold lags the grant it delivers. Park quietly and
6169    /// re-judge after the next control follow. NOT an anomaly — a lagging fold
6170    /// is the normal case for a vend that races its own Grant.
6171    Park(&'static str),
6172    /// Judged invalid against evidence that cannot become true later. Alarm-worthy.
6173    Refuse(&'static str),
6174}
6175
6176/// Judge a vended Private-Channel key against our OWN folded state.
6177///
6178/// The Grant is the authority half and rides the owner-rooted control plane, so
6179/// it cannot be forged; the vend is only delivery. Acceptance therefore rests
6180/// entirely on what our own fold proves — a bundle can never introduce a channel
6181/// our control plane doesn't define, which is what closes the hidden-channel
6182/// injection class.
6183///
6184/// `community` must already be the held (self-certified) community: the caller
6185/// resolves it by `community_id`, so a bundle naming a community we're not in is
6186/// never judged here at all.
6187pub fn judge_channel_key_vend(
6188    community: &CommunityV2,
6189    roster: &crate::community::roles::CommunityRoles,
6190    channel_id: &ChannelId,
6191    epoch: Epoch,
6192    sender_hex: &str,
6193) -> VendVerdict {
6194    let me = match me_pk() {
6195        Ok(pk) => pk.to_hex(),
6196        Err(_) => return VendVerdict::Park("no active identity"),
6197    };
6198    let owner_hex = match community.owner() {
6199        Ok(o) => o.to_hex(),
6200        Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
6201    };
6202    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6203
6204    // (2) The channel must exist in OUR fold, and be private there. The bundle's
6205    // own claims are ignored: a vend may deliver a key, never define a channel.
6206    let Some(ch) = community.channel(channel_id) else {
6207        return VendVerdict::Park("channel not in our fold yet");
6208    };
6209    if !ch.private {
6210        // Never heals: our owner-rooted fold says this channel is public, so a
6211        // "private key" for it is a spoof, not a lagging view.
6212        return VendVerdict::Refuse("vend names a channel our fold says is public");
6213    }
6214
6215    // (5) Epoch sanity, BOTH directions. Below is superseded by the rotation that
6216    // produced our copy. Above matters more: the channel head is monotonic, so a
6217    // wildly-ahead epoch is not merely wrong, it is PERMANENT — every genuine
6218    // rotation afterwards lands at `head + 1`, is refused as stale, and the
6219    // channel dies for us with no heal path at all (not a rekey, not a re-grant,
6220    // not a refound). Rotations advance one epoch at a time, so a lead this large
6221    // is never a delivery we could place.
6222    if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
6223        return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
6224    }
6225    if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
6226        return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
6227    }
6228
6229    // (3) OUR fold must show US granted a role scoped to this channel. This is
6230    // the rule that kills the spoof class: an attacker cannot forge the Grant,
6231    // so they cannot make us accept a key for a channel we were never granted.
6232    if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
6233        return VendVerdict::Park("our grant for this channel has not folded yet");
6234    }
6235
6236    // (4) The vendor must be entitled too — they hold the real key, so a wrong
6237    // key from them costs isolation, never confidentiality.
6238    if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
6239        return VendVerdict::Park("vendor's entitlement has not folded yet");
6240    }
6241
6242    VendVerdict::Accept
6243}
6244
6245/// How long an unprovable parked vend is kept. Deliberately long: the fallback
6246/// heal is the channel's next rotation, which may never come.
6247const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
6248
6249/// How far above our channel head a vend may claim to be. Generous — a keyless
6250/// cursor can lag a busy channel by many rotations — but bounded, because the
6251/// head is monotonic and an over-advance can never be walked back.
6252const MAX_VEND_EPOCH_LEAD: u64 = 1024;
6253
6254/// Re-judge every parked key vend for this community and adopt the ones that now
6255/// pass. Runs after a control follow (the fold moved, so verdicts can change) and
6256/// on the boot sweep.
6257///
6258/// Returns the channels newly keyed up.
6259pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &std::sync::Arc<crate::db::Session>) -> Vec<ChannelId> {
6260    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6261    let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
6262        Ok(p) if !p.is_empty() => p,
6263        _ => return Vec::new(),
6264    };
6265    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6266    let mut adopted = Vec::new();
6267    let now = now_ms() / 1000;
6268    for p in parked {
6269        // Several candidates may name one channel (parking is open to any sender,
6270        // so a stranger can never suppress the entitled vendor's key by holding a
6271        // slot). Once one is seated the rest are moot.
6272        if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
6273            let _ = crate::db::community::drop_pending_channel_key(p.id);
6274            continue;
6275        }
6276        // A vend we were never able to prove is not kept forever: an admin who
6277        // adds then immediately removes someone leaves a row nothing will ever
6278        // discharge. Generous by design — the alternative heal (the channel's
6279        // next rotation) can be arbitrarily far away, so this is hygiene, not a
6280        // deadline.
6281        if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
6282            let _ = crate::db::community::drop_pending_channel_key(p.id);
6283            continue;
6284        }
6285        let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
6286            let _ = crate::db::community::drop_pending_channel_key(p.id);
6287            continue;
6288        };
6289        let channel_id = ChannelId(id_bytes);
6290        match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
6291            VendVerdict::Accept => {
6292                if !session.is_live() {
6293                    return adopted;
6294                }
6295                // First delivery vs rotation. A keyless channel must bypass the
6296                // monotonic guard: it sits at the epoch-0 cursor, and a peer that
6297                // mints born-private channels at epoch 0 vends that same epoch, so
6298                // `new > current` would refuse the only key on offer.
6299                let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
6300                let seated = if keyless {
6301                    crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
6302                } else {
6303                    crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
6304                };
6305                if let Err(e) = seated {
6306                    crate::log_warn!("v2: adopting a vended channel key failed: {e}");
6307                    continue;
6308                }
6309                // The key landed — every other candidate for this channel is moot.
6310                let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
6311                adopted.push(channel_id);
6312            }
6313            VendVerdict::Refuse(why) => {
6314                crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
6315                // Only THIS candidate — a sibling may still be the genuine vend.
6316                let _ = crate::db::community::drop_pending_channel_key(p.id);
6317            }
6318            // Quiet by design: the fold simply hasn't caught up.
6319            VendVerdict::Park(_) => {}
6320        }
6321    }
6322    adopted
6323}
6324
6325/// Grant `member` read access to a Private channel (CORD-03 "delivered on
6326/// grant"): publish a Grant adding the channel's access role, then vend the key
6327/// as a CORD-05 §6 Direct Invite whose bundle carries exactly the channels they
6328/// are now entitled to.
6329///
6330/// The Grant is the authority half and rides the owner-rooted control plane, so
6331/// it cannot be forged; the vend is only delivery. A recipient accepts the key
6332/// solely on the strength of their OWN fold showing this grant — the bundle can
6333/// never introduce a channel their control plane doesn't define.
6334pub async fn grant_channel_access<T: Transport + ?Sized>(
6335    transport: &T,
6336    community: &CommunityV2,
6337    channel_id: &ChannelId,
6338    member: &PublicKey,
6339) -> Result<(), String> {
6340    crate::db::scoped(async move {
6341        let my_pk = me_pk()?;
6342        let ch = community.channel(channel_id).ok_or("unknown channel")?;
6343        if !ch.private {
6344            return Err("channel is public — every member already reads it".to_string());
6345        }
6346        if ch.key.is_none() {
6347            return Err("we hold no key for this channel, so we cannot vend it".to_string());
6348        }
6349        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6350        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6351        let owner_hex = community.owner()?.to_hex();
6352        // A Grant REPLACES the member's role set, so the union it is built from must
6353        // be CURRENT: a stale local roster would silently strip every role this
6354        // client hasn't folded yet. Fetch the authority fresh rather than trusting
6355        // the cache, and merge the local view on top so a role we just published
6356        // ourselves (which the plane has but no fold has read back) survives too.
6357        let view = fetch_authority(transport, community).await;
6358        let mut roster = view.roles.clone();
6359        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6360        for r in cached.roles {
6361            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6362                roster.roles.push(r);
6363            }
6364        }
6365        for g in cached.grants {
6366            if !roster.grants.iter().any(|x| x.member == g.member) {
6367                roster.grants.push(g);
6368            }
6369        }
6370        let my_hex = my_pk.to_hex();
6371        let member_hex = member.to_hex();
6372        // Reader-gated by MANAGE_ROLES, like any Grant; narrowed to this channel so
6373        // a channel-scoped manager can run its own access list.
6374        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6375            return Err("not authorized to manage this channel's access".to_string());
6376        }
6377        // Rank as well as the bit: the fold drops a Grant aimed at a peer or superior
6378        // (CORD-04 §2), so publishing one vends a key against a role set no reader
6379        // will honour. The owner is the exception — never a valid rank target, but a
6380        // legitimate recipient of a channel key an admin minted.
6381        if member_hex != owner_hex {
6382            if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6383                return Err("you do not outrank this member".to_string());
6384            }
6385            // A Grant replaces whole — a floored-but-unserved head would publish a
6386            // wipe. The owner holds no Grant (their authority is implicit), so there
6387            // is no head to insist on and nothing a first edition could erase.
6388            require_grant_head(community, &view, &member_hex)?;
6389        }
6390        // The channel's roles are ordered by AUTHORITY, so `.first()` is the most
6391        // privileged — granting read access must never hand out a per-channel
6392        // moderator role that happens to share the scope. Pick the permission-less
6393        // one: conferring read access is exactly what carries no authority.
6394        let role_id = roster
6395            .channel_roles(&chan_hex)
6396            .into_iter()
6397            .find(|r| r.permissions == crate::community::roles::Permissions::empty())
6398            .map(|r| r.role_id.clone())
6399            .ok_or("channel has no permission-less access role to grant")?;
6400
6401        let mut role_ids: Vec<String> = roster.roles_of(&member_hex).map(|r| r.role_id.clone()).collect();
6402        if !role_ids.contains(&role_id) {
6403            role_ids.push(role_id.clone());
6404        }
6405        // The access role is permission-less (never staff-making), but a KEPT role
6406        // can be — the fetched roster judges (CORD-04 §3).
6407        grant_roles_with_roster(transport, community, member, role_ids.clone(), &roster).await?;
6408        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
6409        // Settle the vend against the Grant we JUST published — the fold lags it.
6410        let bundle = bundle_of_with_overlay(
6411            community,
6412            BundleAudience::Member(*member),
6413            Some(my_pk),
6414            None,
6415            None,
6416            std::slice::from_ref(&role_id),
6417            &[],
6418        );
6419        let signer = crate::signer::active_signer()?;
6420        let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
6421        transport.publish(&wrap, &community.relays).await?;
6422        Ok(())
6423    })
6424    .await
6425}
6426
6427/// Revoke `member`'s read access to a Private channel (CORD-03 "rekeyed on
6428/// removal"): drop the channel's access role from their Grant, then rotate the
6429/// channel to its next epoch delivering the fresh key to everyone still
6430/// entitled (CORD-06). The revoked member keeps whatever history they already
6431/// read — a rekey protects the future, never the past.
6432pub async fn revoke_channel_access<T: Transport + ?Sized>(
6433    transport: &T,
6434    community: &CommunityV2,
6435    channel_id: &ChannelId,
6436    member: &PublicKey,
6437) -> Result<(), String> {
6438    crate::db::scoped(async move {
6439        let my_pk = me_pk()?;
6440        let ch = community.channel(channel_id).ok_or("unknown channel")?;
6441        if !ch.private {
6442            return Err("channel is public — there is no access to revoke".to_string());
6443        }
6444        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6445        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6446        let owner_hex = community.owner()?.to_hex();
6447        // Same replace-not-merge hazard as the grant: the retained set must be built
6448        // from a CURRENT roster or this revoke strips roles we simply hadn't folded.
6449        let view = fetch_authority(transport, community).await;
6450        let mut roster = view.roles.clone();
6451        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6452        for r in cached.roles {
6453            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6454                roster.roles.push(r);
6455            }
6456        }
6457        for g in cached.grants {
6458            if !roster.grants.iter().any(|x| x.member == g.member) {
6459                roster.grants.push(g);
6460            }
6461        }
6462        let my_hex = my_pk.to_hex();
6463        let member_hex = member.to_hex();
6464        if !roster.is_authorized_in(&my_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6465            return Err("not authorized to manage this channel's access".to_string());
6466        }
6467        if *member == community.owner()? {
6468            return Err("the owner is supreme and cannot be removed".to_string());
6469        }
6470        // Rank as well as the bit, and it matters more here than on the grant side:
6471        // readers drop the Grant, but the channel rotation below is gated on the
6472        // citation alone, so an unauthorized revoke still severs its target.
6473        if !roster.can_act_on_member(&my_hex, Some(&owner_hex), &member_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
6474            return Err("you do not outrank this member".to_string());
6475        }
6476        // A Grant replaces whole — a floored-but-unserved head would publish a wipe.
6477        require_grant_head(community, &view, &member_hex)?;
6478        let access_ids = roster.channel_role_ids(&chan_hex);
6479        // Without the access list this revoke is a no-op that still ROTATES, and the
6480        // rotation's recipient filter would match nobody — cutting off every
6481        // legitimately entitled member. Refuse rather than mass-evict.
6482        if access_ids.is_empty() {
6483            return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
6484        }
6485        let remaining: Vec<String> = roster
6486            .roles_of(&member_hex)
6487            .map(|r| r.role_id.clone())
6488            .filter(|id| !access_ids.contains(id))
6489            .collect();
6490        grant_roles_with_roster(transport, community, member, remaining.clone(), &roster).await?;
6491        merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
6492        // Rotate so the removal actually severs them (CORD-06 §1). The revoked
6493        // member is excluded from the recipient set by the overlay, since the fold
6494        // has not yet caught the Grant we just published.
6495        rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, std::slice::from_ref(member)).await
6496    })
6497    .await
6498}
6499
6500/// Rotate one Private channel to its next epoch, delivering the fresh key to
6501/// everyone entitled EXCEPT `removed` (CORD-06 §1 single-channel rekey).
6502///
6503/// `roster` must be the caller's CURRENT view (fetched, not the local cache):
6504/// the recipient set is built from it, so a cached roster silently drops every
6505/// member granted since this client last folded — they keep a dead key with no
6506/// heal path. `access_ids` is that roster's access-role set for this channel;
6507/// `removed` is excluded explicitly, since the revoking Grant was published
6508/// moments ago and no fold has caught it. A batch ban excludes its whole wave
6509/// in ONE rotation — that is why this takes a slice.
6510async fn rekey_channel_excluding<T: Transport + ?Sized>(
6511    transport: &T,
6512    community: &CommunityV2,
6513    channel_id: &ChannelId,
6514    roster: &crate::community::roles::CommunityRoles,
6515    access_ids: &[String],
6516    removed: &[PublicKey],
6517) -> Result<(), String> {
6518    crate::db::scoped(async move {
6519        // Whole-row save below — serialize with the follow worker (see create_*_channel).
6520        let lock = super::realtime::follow_lock(community.id());
6521        let _guard = lock.lock().await;
6522        let signer = crate::signer::active_signer()?;
6523        let my_pk = me_pk()?;
6524        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6525        let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6526        let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
6527        let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
6528        let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
6529        let owner = community.owner()?;
6530        let owner_hex = owner.to_hex();
6531
6532        // Everyone still entitled: the owner (always), me (the rotator must be able
6533        // to read what it rekeys), and every member the roster shows holding an
6534        // access role — minus the removals.
6535        let removed_hexes: std::collections::HashSet<String> = removed.iter().map(|p| p.to_hex()).collect();
6536        let mut recipients: Vec<PublicKey> = vec![my_pk];
6537        if owner != my_pk {
6538            recipients.push(owner);
6539        }
6540        for g in &roster.grants {
6541            if removed_hexes.contains(&g.member) || g.member == owner_hex {
6542                continue;
6543            }
6544            if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
6545                continue;
6546            }
6547            if let Ok(pk) = PublicKey::parse(&g.member) {
6548                if !recipients.contains(&pk) {
6549                    recipients.push(pk);
6550                }
6551            }
6552        }
6553        // Mint-or-reuse keyed by (channel, next epoch) so a retry after a partial
6554        // publish re-uses the same key instead of forking the epoch.
6555        let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
6556        let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
6557        let mut blobs = Vec::with_capacity(recipients.len());
6558        for r in &recipients {
6559            blobs.push(
6560                rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
6561                    .await
6562                    .map_err(|e| e.to_string())?,
6563            );
6564        }
6565        let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
6566        let at_secs = now_ms() / 1000;
6567        let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref(), false)
6568            .await
6569            .map_err(|e| e.to_string())?;
6570        for c in &chunks {
6571            transport.publish_durable(c, &community.relays).await?;
6572        }
6573        if crate::db::community::community_protocol(community.id())?.is_none() {
6574            return Err("community removed during channel rekey".to_string());
6575        }
6576        // Adopt locally + archive, so our own history reads across the rotation.
6577        crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6578        crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
6579
6580        // §7 Rotator duty: reseal the Pin List under the NEW key. Without this,
6581        // members who join at this epoch hold no old key and the channel's pins
6582        // read as sealed-dark for them forever. The rotator is uniquely placed:
6583        // it provably reads the old seal (it held the old key) and mints the new
6584        // one. Best-effort — a failed reseal never fails the rotation, and any
6585        // curator's next edit heals the same way.
6586        {
6587            let mut rotated = community.clone();
6588            if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
6589                c.key = Some(new_key);
6590                c.epoch = new_epoch;
6591            }
6592            match read_channel_pins(&rotated, channel_id) {
6593                Ok(read) if !read.sealed && !read.pins.is_empty() => {
6594                    let entries: Vec<super::pins::PinEntry> =
6595                        read.pins.iter().map(|p| p.entry.clone()).collect();
6596                    if let Some(ch2) = rotated.channel(channel_id).cloned() {
6597                        if let Err(e) = publish_pin_list(transport, &rotated, &ch2, &entries).await {
6598                            crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
6599                        } else {
6600                            crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
6601                        }
6602                    }
6603                }
6604                Ok(read) if read.sealed => {
6605                    crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
6606                }
6607                _ => {}
6608            }
6609        }
6610        Ok(())
6611    })
6612    .await
6613}
6614
6615/// Rotate every private channel a just-banned member could read (CORD-06 §1 applied
6616/// per channel). A Public-community Ban skips the Refounding (CORD-05 §5), but the
6617/// banlist and grant strip alone leave the member holding each private channel's
6618/// CURRENT epoch key — rotation is the only read severance.
6619///
6620/// Takes the whole banned wave: a channel several targets could read rotates ONCE,
6621/// excluding them all. Per-target rotation would mint N epochs for one ban wave and
6622/// churn every legitimate reader N times.
6623///
6624/// Each target carries its role set as captured before the strip: the fold may or
6625/// may not have caught the strip yet, and the `with` overlay makes the entitlement
6626/// judgment independent of that timing.
6627///
6628/// Offer-side mirror of the reader's `channel_rotator_ok`: a channel rotation is
6629/// honored only from `MANAGE_CHANNELS` holders, so a BAN-only moderator must not
6630/// publish one — locally adopting an epoch every reader rejects forks the channel.
6631///
6632/// Best-effort per channel (one failure must not leave the others unrotated);
6633/// returns how many channels rotated, or the joined failures.
6634pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
6635    transport: &T,
6636    community: &CommunityV2,
6637    targets: &[(PublicKey, Vec<String>)],
6638) -> Result<usize, String> {
6639    crate::db::scoped(async move {
6640        let my_pk = me_pk()?;
6641        ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
6642        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6643        // Recipients need the CURRENT entitlement view — fetched, then merged over the
6644        // cache so entitlements we published ourselves survive a lagging fold (the same
6645        // two-strand merge as `revoke_channel_access`).
6646        let mut roster = fetch_authority(transport, community).await.roles;
6647        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6648        for r in cached.roles {
6649            if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
6650                roster.roles.push(r);
6651            }
6652        }
6653        for g in cached.grants {
6654            if !roster.grants.iter().any(|x| x.member == g.member) {
6655                roster.grants.push(g);
6656            }
6657        }
6658        let owner_hex = community.owner().ok().map(|o| o.to_hex());
6659        let mut rotated = 0usize;
6660        let mut failures: Vec<String> = Vec::new();
6661        for ch in &community.channels {
6662            if !ch.private || ch.key.is_none() {
6663                continue;
6664            }
6665            let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6666            // Everyone in the wave who could read this channel, cut in ONE rotation.
6667            let excluded: Vec<PublicKey> = targets
6668                .iter()
6669                .filter(|(pk, stripped)| roster.is_entitled(owner_hex.as_deref(), &pk.to_hex(), &chan_hex, stripped, &[]))
6670                .map(|(pk, _)| *pk)
6671                .collect();
6672            if excluded.is_empty() {
6673                continue;
6674            }
6675            let access_ids = roster.channel_role_ids(&chan_hex);
6676            // Entitlement without an access-role set can't happen for a non-owner, and
6677            // an empty set would make the rotation's recipient filter mass-evict.
6678            if access_ids.is_empty() {
6679                continue;
6680            }
6681            // Reload per iteration: each rotation advances the held document, and a
6682            // stale struct would mint a colliding channel epoch on the next pass.
6683            let held = match crate::db::community::load_community_v2(community.id()) {
6684                Ok(Some(c)) => c,
6685                _ => return Err("community gone during ban severance".to_string()),
6686            };
6687            match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, &excluded).await {
6688                Ok(()) => rotated += 1,
6689                Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
6690            }
6691        }
6692        if failures.is_empty() {
6693            Ok(rotated)
6694        } else {
6695            Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
6696        }
6697    })
6698    .await
6699}
6700
6701/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
6702/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
6703pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
6704    crate::db::scoped(async move {
6705        // Whole-row save below — serialize with the follow worker (see create_*_channel).
6706        let lock = super::realtime::follow_lock(community.id());
6707        let _guard = lock.lock().await;
6708        let my_pk = me_pk()?;
6709        ensure_channel_manager(community, &my_pk)?;
6710        // The tombstone carries the FULL held document (deleted flag set): a strict
6711        // reader treats an edition as the entity, so even a deletion must not strip
6712        // fields it didn't touch (CORD-02 §6).
6713        let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
6714            name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
6715        });
6716        meta.deleted = Some(true);
6717        let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
6718        publish_control_edition(transport, community, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
6719        let mut updated = community.clone();
6720        updated.channels.retain(|c| c.id.0 != channel_id.0);
6721        crate::db::community::save_community_v2(&updated)?;
6722        Ok(())
6723    })
6724    .await
6725}
6726
6727// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
6728
6729/// Re-fold this community's Control Plane and apply the current metadata +
6730/// **public** channel set to the held community, persisting any change. Called
6731/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
6732/// edited description) so a long-running bot tracks the community mid-session
6733/// instead of freezing at its join-time view.
6734///
6735/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
6736/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
6737/// edition is eligible only if its signer CURRENTLY holds the entity's management
6738/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
6739/// a demoted one's drop. The owner is supreme, proven by the self-certifying
6740/// community_id (no network trust).
6741///
6742/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
6743/// delivered over the rekey plane (or an invite bundle), never derivable from a
6744/// control edition alone. A new Private channel therefore surfaces only once
6745/// [`follow_rekeys`] delivers its key. Public channels derive from the
6746/// community_root, so they fold in directly.
6747///
6748/// Returns the updated community iff something changed (so the caller can skip a
6749/// redundant re-subscribe + refresh notification).
6750/// What a control fold moved. `updated` is the community DOCUMENT (metadata,
6751/// channels, adopted control root); `authority_changed` is the roster/banlist/
6752/// registry — which live in their own columns and so are invisible in `updated`.
6753///
6754/// The distinction is load-bearing: the rekey walk's authority gates read exactly
6755/// the state `authority_changed` covers, so a caller that keys its retry off
6756/// `updated` alone will refuse a rotation on pass 1, silently fix the roster on
6757/// pass 2, and never look again. That is a permanent strand — and a strand is a
6758/// fork to everyone else.
6759#[derive(Debug, Default)]
6760pub struct ControlFollow {
6761    pub updated: Option<CommunityV2>,
6762    pub authority_changed: bool,
6763}
6764
6765impl ControlFollow {
6766    /// Did anything at all move? Either half warrants another rekey walk.
6767    pub fn moved(&self) -> bool {
6768        self.updated.is_some() || self.authority_changed
6769    }
6770}
6771
6772pub async fn follow_control<T: Transport + ?Sized>(
6773    transport: &T,
6774    community: &CommunityV2,
6775) -> Result<ControlFollow, String> {
6776    crate::db::scoped(async move {
6777        community.owner()?; // fail fast if the community is somehow unproven.
6778        let control = control::ControlPlane::of(community);
6779        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6780
6781        // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
6782        // under a prior epoch is excluded, so that entity auto-bootstraps after a
6783        // Refounding (Armada accepts a compacted head across a dangling prev — matched).
6784        // A read error FAILS CLOSED: an empty map would silently re-open the rollback
6785        // window the floor exists to shut.
6786        let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
6787            .into_iter()
6788            .filter(|(_, f)| f.0 == community.root_epoch.0)
6789            .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
6790            .collect();
6791
6792        // Newest window first; page OLDER only while a tracking entity is gapped (its
6793        // floor link evicted from the window — H1/M8 refetch), bounded like the join
6794        // verifier. A withholding relay still converges to fail-closed after the cap.
6795        let mut editions: Vec<ParsedEdition> = Vec::new();
6796        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
6797        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
6798        let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
6799        let mut authority = AuthoritySet::owner_only();
6800        // Whether this round gave up with editions still unread. The follow is
6801        // procedural by design — process what arrives, converge with everyone else —
6802        // so a short read never blocks reading, writing or epoch adoption. It only
6803        // withholds the ROSTER cache below: caching a partial authority as this
6804        // device's baseline is the one step that outlives the round.
6805        let mut truncated = true;
6806        let mut pages_read = 0usize;
6807        // Quorum answers as soon as a majority does, so a fast subset that happens not
6808        // to hold an older edition is indistinguishable from that edition not existing —
6809        // and the same subset answers every boot, so the retain never lifts and the
6810        // roster cache freezes with no way back. This fold draws an ABSENCE verdict
6811        // (`stored_complete`, `truncated`), and CORD-04 §5 makes a gap fail closed FOR
6812        // THAT ENTITY and refetch. So escalate: read at Quorum, and if anything we
6813        // already hold is still unaccounted for, read again at Full before concluding.
6814        // Healthy folds never pay for the second tier.
6815        for tier in [
6816            crate::community::transport::Evidence::Quorum,
6817            crate::community::transport::Evidence::Full,
6818        ] {
6819            let mut until: Option<u64> = None;
6820            let mut oldest: Option<u64> = None;
6821            truncated = true;
6822            for _ in 0..FOLLOW_MAX_PAGES {
6823                pages_read += 1;
6824                let query = Query {
6825                    kinds: vec![stream::KIND_WRAP],
6826                    authors: vec![control.pk_hex()],
6827                    until,
6828                    limit: Some(FOLLOW_PAGE),
6829                    evidence: tier,
6830                    ..Default::default()
6831                };
6832                let wraps = transport.fetch(&query, &community.relays).await?;
6833                // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
6834                // at a page boundary); the wrap-id dedup makes re-served boundary events
6835                // free, and a page with nothing new means the relay is exhausted.
6836                let mut fresh = 0usize;
6837                for w in &wraps {
6838                    if !seen_wraps.insert(w.id) {
6839                        continue;
6840                    }
6841                    fresh += 1;
6842                    let at = w.created_at.as_secs();
6843                    if oldest.is_none_or(|o| at < o) {
6844                        oldest = Some(at);
6845                    }
6846                    // Open + seal-verify every edition; authority is resolved by the roster
6847                    // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
6848                    if let Ok((ed, _)) = control.open(w) {
6849                        if seen.insert(ed.inner_id) {
6850                            editions.push(ed);
6851                        }
6852                    }
6853                }
6854                // Roster first (roles/grants/banlist → authorized set), then the authority-
6855                // gated metadata/channel fold over the same edition set.
6856                authority = fold_authority(community, &editions, &floors);
6857                fold = apply_control_fold(community, &editions, &floors, &authority);
6858                // "Nothing is gapped" is NOT "I have read enough".
6859                //
6860                // An entity we track whose editions are entirely absent from the window
6861                // folds away silently — absence dangles no link, so it raises no gap. The
6862                // loop therefore declared the very first page coherent and stopped, and a
6863                // community whose grants sit older than one page could never re-derive its
6864                // own roster: every fold offered zero grants, and only the completeness
6865                // guard retaining the cache kept the admin list alive at all. That made the
6866                // cache the sole surviving copy of the roster rather than a cache.
6867                //
6868                // So keep paging while anything we already know about is still unaccounted
6869                // for. This only ever reads MORE; it cannot lose data, and it terminates on
6870                // the page cap or an exhausted plane exactly as before.
6871                let heads_now: std::collections::HashSet<&str> =
6872                    authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6873                // A FAILED read must not read as "I track nothing" — that makes
6874                // "everything is accounted for" trivially true and stops the walk at the
6875                // first page, which is the exact failure this loop exists to prevent.
6876                // Fail toward reading MORE.
6877                let all_accounted = match crate::db::community::get_community_roles(&cid_hex) {
6878                    Ok(known) => {
6879                        // A roster we have not built yet accounts for nothing, so
6880                        // "everything I track is present" is vacuously TRUE and stops a
6881                        // COLD device at the first page — caching whatever the newest
6882                        // window happened to hold as its whole baseline, and losing every
6883                        // grant older than one page. The early break is an optimisation
6884                        // for a client that already knows what it is looking for.
6885                        (!known.roles.is_empty() || !known.grants.is_empty())
6886                            && known.roles.iter().all(|r| heads_now.contains(r.role_id.as_str()))
6887                            && known.grants.iter().all(|g| {
6888                                crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6889                                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6890                                    heads_now.contains(eid.as_str())
6891                                })
6892                            })
6893                    }
6894                    Err(e) => {
6895                        crate::log_warn!("[v2:control {}] roster read failed mid-page ({e}) — paging on rather than assuming nothing is tracked", &cid_hex[..8.min(cid_hex.len())]);
6896                        false
6897                    }
6898                };
6899                if !(fold.gapped || authority.gapped) && all_accounted {
6900                    truncated = false; // coherent AND complete for everything we track
6901                    break;
6902                }
6903                if fresh == 0 {
6904                    // A FULL page with nothing new is a same-second wall no `until` steps
6905                    // past, so older editions stay unreachable; a short page is the end
6906                    // of the plane, and a gap in THAT is the relay withholding, not us
6907                    // giving up early.
6908                    truncated = wraps.len() >= FOLLOW_PAGE;
6909                    break;
6910                }
6911                until = oldest;
6912            }
6913
6914            if tier == crate::community::transport::Evidence::Full {
6915                break;
6916            }
6917            let stored_so_far = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6918            let heads_so_far: std::collections::HashSet<&str> =
6919                authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6920            let accounted = stored_so_far.roles.iter().all(|r| !floors.contains_key(&r.role_id) || heads_so_far.contains(r.role_id.as_str()))
6921                && stored_so_far.grants.iter().all(|g| {
6922                    crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6923                        let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
6924                        !floors.contains_key(&eid) || heads_so_far.contains(eid.as_str())
6925                    })
6926                });
6927            if accounted {
6928                break;
6929            }
6930            crate::log_debug!(
6931                "[v2:control {}] {} stored entit(ies) unaccounted after a Quorum read — re-reading at Full before drawing an absence verdict",
6932                &cid_hex[..8.min(cid_hex.len())],
6933                stored_so_far.roles.len() + stored_so_far.grants.len()
6934            );
6935        }
6936
6937        // The fetches straddled awaits; a swap since the guard was captured must not
6938        // write account A's control state into B.
6939        // A leave/delete raced this follow: writing now would resurrect the community
6940        // row and orphan floor rows past delete_community's wipe.
6941        if crate::db::community::community_protocol(community.id())?.is_none() {
6942            return Ok(ControlFollow::default());
6943        }
6944        // Persist advanced floors BEFORE the state save (a failed floor write must not
6945        // let saved state outrun its floor), stamping the epoch this fold ran under —
6946        // not the row's write-time value, which a concurrent re-founding can bump. Both
6947        // the metadata/channel heads and the roster/banlist heads advance their floors;
6948        // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
6949        for h in fold.heads.iter().chain(authority.heads.iter()) {
6950            crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
6951            crate::db::community::converge_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, community.root_epoch.0)?;
6952        }
6953        // Adopt the staff write key a promotion delivered inside MY OWN Grant
6954        // (CORD-04 §3): decrypt the control_wrap under the granter↔me pairwise key
6955        // and record the secret. Verified fail-closed inside — the epoch must be
6956        // the one I'm on and the secret must derive to exactly the control_pk I
6957        // hold — so a stale wrap (compaction re-wraps a Grant head verbatim across
6958        // Refoundings) or a forged one is dropped, never adopted.
6959        let adopted_control_root = adopt_my_control_wrap(community, &editions).await;
6960        // The adopt can await a remote signer (bunker NIP-44 round-trip) — re-validate
6961        // before the per-account writes below.
6962        // Persist the authorized banlist content (retained/withholding folds carry None,
6963        // so the stored banlist is left intact — an anti-roster never silently un-bans).
6964        let mut authority_changed = false;
6965        // Ban marks MERGE (never replace): they must outlive both the ban and this window,
6966        // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
6967        // itself was retained — the history is what the suppression reads.
6968        let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
6969        if let Some((banned, version)) = &authority.banlist_persist {
6970            let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
6971            crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
6972            let mut after = banned.clone();
6973            before.sort();
6974            after.sort();
6975            authority_changed |= before != after;
6976        }
6977        // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
6978        // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
6979        // fetch path: only an aggregate built from roster editions at least as new as
6980        // the stored one may replace it — a withholding relay serving NO roster
6981        // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
6982        // and that must RETAIN the stored roster, never wipe standing.
6983        let newest_roster_at: i64 = editions
6984            .iter()
6985            .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
6986            .map(|e| e.created_at as i64)
6987            .max()
6988            .unwrap_or(0);
6989        // Completeness gate: the `gapped` flag only covers entities present in the window.
6990        // A role/grant floored on this device but with ZERO editions fetched (aged out of
6991        // the paging reach) folds absent yet raises no gap — persisting would silently drop
6992        // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
6993        // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
6994        let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
6995        let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
6996        let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
6997            && stored.grants.iter().all(|g| {
6998                crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
6999                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
7000                    !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
7001                })
7002            });
7003        // `truncated` covers the case the other three can't: a COLD device (no floors,
7004        // no stored roster) folding under a plane a member has inflated past the pager.
7005        // `stored_complete` is trivially true with nothing stored, so without this the
7006        // first sync would cache a partial authority as its own baseline.
7007        let stored_at = crate::db::community::get_community_roles_at(&cid_hex)?;
7008        // `stored_at == 0` is a roster with NO plane provenance: the optimistic local
7009        // merge every creator writes (`merge_local_roster`) so a just-made role works
7010        // before its edition folds. It is a guess about the plane, not evidence about
7011        // it, so it must never out-vote a coherent fold — and it was: the seed names
7012        // entities the plane no longer heads, `stored_complete` goes false forever, and
7013        // the roster can never be replaced. A community's OWNER is the likeliest holder
7014        // of such a seed, so the wedge lands on exactly the account whose rotations
7015        // everyone else is waiting to adopt.
7016        let seed_only = stored_at == 0;
7017        // A floor that can NEVER be satisfied must not veto the roster forever.
7018        //
7019        // `stored_complete` retains the cache when a stored entity is floored but
7020        // folded no head, because a withholding relay looks exactly like that. The
7021        // trouble is that an entity can legitimately stop producing a head for good:
7022        // its author was demoted, so `select_authorized` drops their editions; or the
7023        // entity was compacted away. The veto then holds forever, the roster can never
7024        // be replaced, and a client with a stale roster refuses every rotation from an
7025        // admin promoted since — which is a fork, arrived at silently.
7026        //
7027        // So when the read was COHERENT — the whole plane paged, nothing gapped, and
7028        // the fetch itself is quorum-backed — an entity that produced no head is
7029        // genuinely not there as far as the reachable relay set can tell. Forget its
7030        // floor and let the roster move. A read that was truncated or gapped proves
7031        // nothing and still retains, which is the withholding case the guard exists
7032        // for. My OWN grant is never pruned: losing my own standing on a bad read is
7033        // the one mistake with no path back.
7034        // NO FLOOR PRUNING. An attempt at it evicted thirteen real admins from a live
7035        // community: their grant editions were merely outside the fetched window, and
7036        // "the plane is serving newer editions than mine" is not evidence that a
7037        // specific entity is gone. A frozen roster is a fork risk; a WRONG roster is a
7038        // fork plus silent loss of everyone's standing, which is strictly worse. Until
7039        // there is per-entity proof of absence, absence stays unproven.
7040        // NOT gated on `authority.gapped`. That flag is a global OR across every
7041        // entity — its own declaration calls it the per-entity flag that "drives
7042        // older-paging" — so one unresolvable entity anywhere vetoed the whole roster
7043        // cache, permanently once the plane is exhausted and more paging cannot help.
7044        // CORD-04 §5 says a gap fails closed FOR THAT ENTITY, suspending and refetching
7045        // it, and that already happens: a gapped entity produces no head and so is
7046        // simply absent from the fold. Freezing every other entity behind it is the
7047        // over-reach, and it is what left live communities unable to re-derive rosters
7048        // they could otherwise read perfectly.
7049        //
7050        // The downgrade defence is `stored_complete`, which is untouched: nothing we
7051        // already know can silently vanish. An entity we have never held cannot be
7052        // stripped from us by its absence.
7053        if !truncated && (stored_complete || seed_only) && newest_roster_at >= stored_at {
7054            authority_changed |= stored != authority.roles;
7055            crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
7056        } else {
7057            // Loud, because the failure mode is silent and total: the roster is what
7058            // authorizes every rotation, so a client that can never re-cache it is
7059            // permanently deaf to that admin's re-foundings — and the only symptom is
7060            // an epoch that never advances.
7061            // Name the BLOCKERS and what the fold actually found. Without these the
7062            // warning says a decision was made but not on what evidence, which is how
7063            // a wrong roster stays indistinguishable from a cautious one.
7064            let mut blockers: Vec<String> = Vec::new();
7065            for r in &stored.roles {
7066                if floors.contains_key(&r.role_id) && !head_ents.contains(r.role_id.as_str()) {
7067                    blockers.push(format!("role:{}", &r.role_id[..8.min(r.role_id.len())]));
7068                }
7069            }
7070            for g in &stored.grants {
7071                if let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) {
7072                    let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
7073                    if floors.contains_key(&eid) && !head_ents.contains(eid.as_str()) {
7074                        blockers.push(format!("grant:{}", &g.member[..8.min(g.member.len())]));
7075                    }
7076                }
7077            }
7078            // Per-entity fate of every roster edition in the window. "Blocked" only
7079            // says which stored entity produced no head; this says WHY — whether its
7080            // editions were absent, or present and dropped, and by whom they were
7081            // authored. Guessing between those three cost a day.
7082            {
7083                use std::collections::BTreeMap;
7084                let mut fate: BTreeMap<String, (u64, u64, String, &str)> = BTreeMap::new();
7085                for e in &editions {
7086                    if e.vsk != vsk::ROLE && e.vsk != vsk::GRANT {
7087                        continue;
7088                    }
7089                    let kind = if e.vsk == vsk::ROLE { "role" } else { "grant" };
7090                    let eid = crate::simd::hex::bytes_to_hex_32(&e.entity_id);
7091                    let slot = fate.entry(eid).or_insert((0, 0, String::new(), kind));
7092                    slot.0 += 1;
7093                    if e.version > slot.1 {
7094                        slot.1 = e.version;
7095                        slot.2 = e.author.to_hex()[..8].to_string();
7096                        slot.3 = kind;
7097                    }
7098                }
7099                let head_now: std::collections::HashSet<&str> =
7100                    authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
7101                let rows: Vec<String> = fate
7102                    .iter()
7103                    .map(|(eid, (n, v, author, kind))| {
7104                        format!(
7105                            "{}[{kind}]:{}ed/v{}/by{}{}{}",
7106                            &eid[..8], n, v, author,
7107                            if head_now.contains(eid.as_str()) {
7108                                "/HEAD".to_string()
7109                            } else {
7110                                AUTHORITY_REFUSALS
7111                                    .with(|c| c.borrow().get(eid.as_str()).copied())
7112                                    .map(|r| {
7113                                        let extra = MISSING_ROLE_REFS
7114                                            .with(|m| m.borrow().get(eid.as_str()).cloned())
7115                                            .map(|ids| format!(" wants:{ids}"))
7116                                            .unwrap_or_default();
7117                                        format!("/DROPPED({r}{extra})")
7118                                    })
7119                                    .unwrap_or_else(|| "/dropped(no candidate survived the fold)".into())
7120                            },
7121                            floors.get(eid).map(|f| format!("/floor@v{}", f.0)).unwrap_or_default(),
7122                        )
7123                    })
7124                    .collect();
7125                // The OWNER is the root of all authority: every other author needs a
7126                // grant that traces back to them. If no edition here is owner-authored
7127                // the chain cannot bootstrap at all, and that is a different bug from
7128                // any individual entity being rejected.
7129                crate::log_warn!(
7130                    "[v2:control {}] fold accepted roles: [{}]",
7131                    &cid_hex[..8.min(cid_hex.len())],
7132                    authority.roles.roles.iter().map(|r| format!("{}@pos{}", &r.role_id[..8.min(r.role_id.len())], r.position)).collect::<Vec<_>>().join(" ")
7133                );
7134                crate::log_warn!(
7135                    "[v2:control {}] owner={} — roster editions in window: [{}]",
7136                    &cid_hex[..8.min(cid_hex.len())],
7137                    community.owner().map(|o| o.to_hex()[..8].to_string()).unwrap_or_else(|_| "UNPROVEN".into()),
7138                    rows.join(" ")
7139                );
7140            }
7141            crate::log_warn!(
7142                "[v2:control {}] roster NOT cached (truncated={} authority_gapped={} doc_gapped={} stored_complete={} newest_roster_at={} stored_at={}) — stored {}r/{}g, fold offers {}r/{}g from {} edition(s) over {} page(s), blocked by [{}]",
7143                &cid_hex[..8.min(cid_hex.len())], truncated, authority.gapped, fold.gapped, stored_complete, newest_roster_at, stored_at,
7144                stored.roles.len(), stored.grants.len(), authority.roles.roles.len(), authority.roles.grants.len(),
7145                editions.len(), pages_read,
7146                blockers.join(", ")
7147            );
7148        }
7149        // Cache the folded invite Registry so Public/Private stays a sync LOCAL read
7150        // (v1 parity — `invite_registry` is the column every caller reads). Gated like
7151        // the roster: a truncated or gapped window folds an empty registry out of mere
7152        // absence, and persisting that under-states Public — the unsafe direction, since
7153        // it leaves a live link open behind a ban.
7154        if !truncated && !authority.gapped && !fold.gapped {
7155            if let Ok(owner) = community.owner() {
7156                let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
7157                let live = flatten_link_sets(&sets);
7158                let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
7159                before.sort();
7160                if before != live {
7161                    crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
7162                    authority_changed = true;
7163                }
7164                // The per-creator split drives "X has N active invite links" and the
7165                // first-link-flips-Public confirm; it lives in its own table.
7166                crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
7167            }
7168        }
7169        // Folded Pin List heads (CORD-04 §7): raw content per channel. The write
7170        // itself is monotonic on version (atomic in the statement), so a stale
7171        // window racing a publish echo can never regress a newer held head.
7172        for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
7173            match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
7174                Ok(true) => {
7175                    crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
7176                    crate::emit_event(
7177                        "community_pins_updated",
7178                        &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
7179                    );
7180                    note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
7181                }
7182                Ok(false) => {}
7183                Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
7184            }
7185        }
7186        // Roster/banlist moves are invisible in the returned community (they live in
7187        // their own columns), so callers that key a refresh off `updated` would never
7188        // repaint a promote/demote/ban. Announce from the single fold point — it covers
7189        // realtime, boot catch-up and manual sync alike.
7190        if authority_changed {
7191            crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
7192        }
7193        // Owner-side silent Admin widening (PIN_MESSAGES): after the roster has
7194        // folded, so the check reads the settled roles. Once per community per
7195        // process — the fold runs constantly and the upgrade is a one-shot.
7196        {
7197            static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
7198            let first = UPGRADED
7199                .lock()
7200                .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
7201                .unwrap_or(false);
7202            if first {
7203                let _ = upgrade_admin_role_pin_bit(transport, community).await;
7204            }
7205        }
7206        // The pins/upgrade tail awaited network I/O — re-validate before the vault save.
7207        // An adopted staff key rides whatever gets saved (the fold's update, or the
7208        // otherwise-unchanged community) — the vault write that makes it durable.
7209        let mut updated = fold.updated;
7210        if let Some(cr) = adopted_control_root {
7211            let mut u = updated.take().unwrap_or_else(|| community.clone());
7212            u.control_root = Some(cr);
7213            updated = Some(u);
7214        }
7215        match updated {
7216            Some(u) => {
7217                crate::db::community::save_community_v2(&u)?;
7218                Ok(ControlFollow { updated: Some(u), authority_changed })
7219            }
7220            None => Ok(ControlFollow { updated: None, authority_changed }),
7221        }
7222    })
7223    .await
7224}
7225
7226/// Adopt the `control_root` a staff-making Grant delivered to ME (CORD-04 §3).
7227///
7228/// Nothing to adopt on a legacy epoch (no write key exists) or when the secret
7229/// is already held. Candidate editions are MY grant coordinate's, newest
7230/// version first; authority-gating them is unnecessary for adoption — the
7231/// derive check IS the gate (only the true secret derives to the held
7232/// `control_pk`, so the worst a forged edition can do is fail it). The epoch
7233/// rides inside the ciphertext because staleness is structural: compaction
7234/// re-wraps a Grant head verbatim across Refoundings, and staff crossing a
7235/// rotation get the new secret in their 136-byte base blob instead.
7236async fn adopt_my_control_wrap(community: &CommunityV2, editions: &[ParsedEdition]) -> Option<[u8; 32]> {
7237    use nostr_sdk::prelude::AsyncNip44;
7238    let held_pk = community.control_pk?;
7239    if community.control_root.is_some() {
7240        return None;
7241    }
7242    let my_pk = me_pk().ok()?;
7243    let signer = crate::signer::active_signer().ok()?;
7244    let my_eid = super::derive::grant_locator(community.id(), &my_pk.to_bytes());
7245    let mut candidates: Vec<&ParsedEdition> = editions
7246        .iter()
7247        .filter(|e| e.vsk == vsk::GRANT && e.entity_id == my_eid)
7248        .collect();
7249    candidates.sort_by_key(|e| std::cmp::Reverse(e.version));
7250    for ed in candidates {
7251        let Some(wrap_b64) = super::roles::parse_grant_control_wrap(&ed.content) else { continue };
7252        // The granter is the edition's sealed author — the pairwise key's other
7253        // half (one ECDH either side can compute; bunker accounts included).
7254        let Ok(plain_b64) = signer.nip44_decrypt_async(&ed.author, &wrap_b64).await else { continue };
7255        let Ok(plain) = base64_simd::STANDARD.decode_to_vec(plain_b64.as_bytes()) else { continue };
7256        let Ok((epoch, root)) = super::rekey::parse_control_wrap(&plain) else { continue };
7257        if epoch != community.root_epoch {
7258            continue;
7259        }
7260        if super::derive::control_signer_group_key(&root, community.id(), epoch).pk() != held_pk {
7261            continue;
7262        }
7263        crate::log_info!("[v2] adopted the staff write key from my Grant (epoch {})", epoch.0);
7264        return Some(root);
7265    }
7266    None
7267}
7268
7269/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
7270/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
7271///
7272/// Nearly free to raise: both follow loops exit the moment the fold stops being
7273/// gapped, so the cap only binds when something is genuinely missing — exactly
7274/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
7275/// under a plane that 100 roles + 400 grants already outgrows before counting
7276/// superseded versions, which accumulate until a compaction retires them.
7277/// One walk's hop bound. A backlog deeper than this converges across passes,
7278/// because every adopted hop is checkpointed before the next is attempted.
7279const MAX_WALK_STEPS: usize = 128;
7280const FOLLOW_MAX_PAGES: usize = 32;
7281const FOLLOW_PAGE: usize = 500;
7282/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
7283/// every Control Event must abort). Far above any real plane, but plane depth is
7284/// attacker-controlled — any member holds the key that mints wraps — so the read
7285/// is bounded and reports coming up short rather than compacting a partial view.
7286const COMPACT_MAX_PAGES: usize = 512;
7287
7288/// A folded control head to persist as the per-entity refuse-downgrade floor.
7289#[derive(Clone)]
7290struct FoldedHead {
7291    entity_hex: String,
7292    version: u64,
7293    self_hash: [u8; 32],
7294    inner_id: [u8; 32],
7295}
7296
7297/// The outcome of a floor-aware control fold: the updated community (if content
7298/// changed), the heads to persist as the new floor (returned even when content is
7299/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
7300/// hit an unresolvable gap — the caller's signal to page older history and re-fold
7301/// (CORD-04 H1/M8's refetch).
7302struct ControlFold {
7303    updated: Option<CommunityV2>,
7304    heads: Vec<FoldedHead>,
7305    gapped: bool,
7306    /// Folded Pin List heads to persist:
7307    /// `(channel_hex, raw content, version, author_npub, created_at)`.
7308    /// Raw carried bytes on purpose — republishing must not re-serialize.
7309    pins_persist: Vec<(String, String, u64, String, u64)>,
7310}
7311
7312/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
7313type Floor = (u64, [u8; 32], Option<[u8; 32]>);
7314type Floors = std::collections::HashMap<String, Floor>;
7315
7316/// Fold owner-authored control editions into an updated community using the
7317/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
7318/// [`version::fold`]`(floor, floor_hash)`:
7319///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
7320///     doesn't block the verified prefix — refuse-downgrade holds for everything
7321///     applied — but flags `gapped` so the caller pages for the rest.
7322///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
7323///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
7324///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
7325///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
7326///     client lands on the same head where a hash-strict floor would wedge forever.
7327///     Anything else is withholding → fail closed + `gapped`.
7328///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
7329///     Refounding, since the caller epoch-filters the floor) takes the highest
7330///     signed head (author already owner-filtered).
7331///
7332/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
7333/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
7334/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
7335/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
7336/// convergence preserved).
7337fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
7338    use crate::community::roles::Permissions;
7339    use std::collections::BTreeMap;
7340
7341    let owner_hex = community.owner().ok().map(|o| o.to_hex());
7342
7343    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
7344    for e in editions {
7345        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
7346    }
7347
7348    let mut out = community.clone();
7349    let mut changed = false;
7350    let mut heads = Vec::new();
7351    let mut gapped = false;
7352    let mut pins_persist = Vec::new();
7353    // Pin List eids are one-way HKDF locators, so attribution runs the other
7354    // direction: precompute every known channel's locator. An eid matching no
7355    // channel folds nothing this round — once the channel's metadata lands, the
7356    // next fold attributes it (editions re-fold from the window each sync).
7357    let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
7358        .channels
7359        .iter()
7360        .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
7361        .collect();
7362    for ((vsk_code, eid), group) in &groups {
7363        // This fold applies three entities: community metadata (eid ==
7364        // community_id), channel metadata, and per-channel Pin Lists. A vsk-2
7365        // whose eid equals the community id is excluded — the floor row keys on
7366        // the entity alone, so it would share (and corrupt) the metadata
7367        // chain's floor.
7368        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
7369        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
7370        let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
7371        if !is_meta && !is_channel && pins_channel.is_none() {
7372            continue;
7373        }
7374        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
7375        // entity's management bit are eligible. Pre-filtering before the fold means a
7376        // demoted admin's (possibly higher-version) edition can't be the head; the
7377        // highest AUTHORIZED head wins. The owner is supreme.
7378        let required = if is_meta {
7379            Permissions::MANAGE_METADATA
7380        } else if is_channel {
7381            Permissions::MANAGE_CHANNELS
7382        } else {
7383            Permissions::PIN_MESSAGES
7384        };
7385        let authed: Vec<&ParsedEdition> = group
7386            .iter()
7387            .copied()
7388            .filter(|e| {
7389                let author = e.author.to_hex();
7390                // A banned npub's edits are dropped (CORD-04 §4), even if they still
7391                // held a bit via a not-yet-stripped grant.
7392                !authority.banned.contains(&author)
7393                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
7394                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
7395                    // this same fold settled, so it works on a bootstrap where no
7396                    // persisted head exists yet.
7397                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7398            })
7399            .collect();
7400        if authed.is_empty() {
7401            continue;
7402        }
7403        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7404        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
7405        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7406        gapped |= entity_gapped;
7407        let Some(hi) = hi else { continue };
7408
7409        let head = authed[hi];
7410        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7411        if is_meta {
7412            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
7413                changed |= apply_community_metadata(&mut out, meta);
7414            }
7415        } else if is_channel {
7416            if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
7417                // vsk-2 carries no community binding (shared v1 grammar); a same-owner
7418                // cross-community replay can inject a phantom PUBLIC channel (bounded:
7419                // root-scoped key, eids don't collide). Binding is a deferred wire change.
7420                changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
7421            }
7422        } else if let Some(channel_hex) = pins_channel {
7423            // The RAW carried content, cap-violations included: readers judge
7424            // those (read as empty), and a re-serialization here would break the
7425            // byte cap's meaning and every republish's fidelity.
7426            use nostr_sdk::prelude::ToBech32;
7427            let author_npub = head
7428                .author
7429                .to_bech32()
7430                .unwrap_or_else(|_| head.author.to_hex());
7431            pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
7432        }
7433    }
7434    ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
7435}
7436
7437/// Fold one entity's editions against its persisted floor into a head index (into the
7438/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
7439/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
7440/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
7441/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
7442/// the lower-inner-id winner; and fail closed otherwise.
7443fn fold_head(fold_eds: &[version::Edition], floor: Option<&Floor>) -> (Option<usize>, bool) {
7444    let floor_v = floor.map(|f| f.0).unwrap_or(0);
7445    if floor_v == 0 {
7446        return (version::bootstrap_head(fold_eds, 0), false);
7447    }
7448    let floor_hash = floor.map(|f| &f.1);
7449    let held_inner = floor.and_then(|f| f.2);
7450    let result = version::fold(fold_eds, floor_v, floor_hash);
7451    if result.anchored {
7452        return (result.head, result.gap); // verified prefix; page any upper gap.
7453    }
7454    if result.head.is_none() && !result.gap {
7455        return (None, false); // everything below floor — a stale relay, no paging.
7456    }
7457    // Unanchored under a held floor: converge a same-version fork at the floor to its
7458    // deterministic winner (lower inner id; a NULL held id is always replaceable),
7459    // else fail closed.
7460    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
7461    let win_hash = match fork {
7462        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
7463        _ => return (None, true), // detached from our committed head → withholding.
7464    };
7465    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
7466    if !re.anchored {
7467        return (None, true);
7468    }
7469    (re.head, re.gap)
7470}
7471
7472/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
7473/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
7474/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
7475/// recomputed from the self-certifying community_id at each use.
7476struct AuthoritySet {
7477    roles: crate::community::roles::CommunityRoles,
7478    banned: std::collections::BTreeSet<String>,
7479    heads: Vec<FoldedHead>,
7480    gapped: bool,
7481    /// The authorized banlist `(content, version)` to persist when an authorized head
7482    /// advanced the floor. `None` when the banlist was retained (no new authorized
7483    /// head) or is empty — the caller then leaves the stored banlist untouched.
7484    banlist_persist: Option<(Vec<String>, u64)>,
7485    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
7486    /// named them, across every edition in the window rather than just the head. Outlives
7487    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
7488    banned_at: std::collections::BTreeMap<String, u64>,
7489}
7490
7491impl AuthoritySet {
7492    /// Bootstrap authority for a community with no roster editions folded yet: only
7493    /// the owner is authorized (supreme), nobody banned.
7494    fn owner_only() -> Self {
7495        AuthoritySet {
7496            roles: Default::default(),
7497            banned: Default::default(),
7498            heads: vec![],
7499            gapped: false,
7500            banlist_persist: None,
7501            banned_at: Default::default(),
7502        }
7503    }
7504}
7505
7506/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
7507/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
7508/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
7509/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
7510/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
7511/// banlist is enforced only if its head's signer held BAN in the authorized roster.
7512fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
7513    use crate::community::roles::Permissions;
7514    use std::collections::BTreeMap;
7515
7516    let cid = community.id();
7517    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
7518    let owner = community.owner().ok();
7519    let owner_hex = owner.map(|o| o.to_hex());
7520    let banlist_eid = super::derive::banlist_locator(cid);
7521    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
7522
7523    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
7524    for e in editions {
7525        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
7526            groups.entry(e.entity_id).or_default().push(e);
7527        }
7528    }
7529
7530    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
7531    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
7532    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
7533    // to the next candidate, so a forged higher-version edition can't suppress the
7534    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
7535    // let any member vanish a role or a member's grant). `gapped` (drives older-
7536    // paging) stays fold_head's per-entity flag.
7537    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7538    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
7539    let mut gapped = false;
7540
7541    for (eid, group) in &groups {
7542        // The banlist is folded author-aware AFTER the roster is known (below).
7543        if *eid == banlist_eid {
7544            continue;
7545        }
7546        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
7547        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
7548        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
7549        gapped |= entity_gapped;
7550        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
7551
7552        for p in group {
7553            // Refuse-downgrade: never consider an edition below the persisted floor.
7554            if p.version < floor_v {
7555                continue;
7556            }
7557            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
7558            match p.vsk.as_str() {
7559                vsk::ROLE => {
7560                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
7561                    if let Some(role) = super::roles::parse_role_content(&p.content) {
7562                        if role.role_id == entity_hex && role.position != 0 {
7563                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
7564                        }
7565                    }
7566                }
7567                vsk::GRANT => {
7568                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
7569                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
7570                            if super::derive::grant_locator(cid, &member) == *eid {
7571                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
7572                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
7573                            }
7574                        }
7575                    }
7576                }
7577                _ => {}
7578            }
7579        }
7580    }
7581    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
7582        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
7583    }
7584
7585    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7586    // Preliminary roster (bans not yet applied) — the authority view the banlist head
7587    // is judged against.
7588    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty, None);
7589
7590    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
7591    // both closed:
7592    //   - head selection: the head is the highest version whose author CURRENTLY holds
7593    //     BAN — an unauthorized higher-version edition can't erase existing bans
7594    //     (fail-open), and the floor never advances to one;
7595    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
7596    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
7597    //     owner is unbannable);
7598    //   - withholding: when no authorized head is served, the persisted banlist is
7599    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
7600    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
7601    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
7602    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
7603    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
7604    // head eligibility, not just from the roster.
7605    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
7606    let banlist_authored: Vec<&ParsedEdition> = groups
7607        .get(&banlist_eid)
7608        .map(|g| {
7609            g.iter()
7610                .copied()
7611                .filter(|e| {
7612                    let ah = e.author.to_hex();
7613                    !banned_authors.contains(ah.as_str())
7614                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
7615                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
7616                })
7617                .collect()
7618        })
7619        .unwrap_or_default();
7620    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
7621    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
7622    // head alone forgets the ban that the suppression exists to remember. The owner is
7623    // skipped: they are never bannable, and a moderator listing them must not durably
7624    // suppress them past the un-ban.
7625    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
7626    for p in &banlist_authored {
7627        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
7628            if owner_hex.as_deref() == Some(t.as_str()) {
7629                continue;
7630            }
7631            let slot = banned_at.entry(t).or_insert(0);
7632            *slot = (*slot).max(p.created_at);
7633        }
7634    }
7635    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
7636    let mut banlist_head: Option<FoldedHead> = None;
7637    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
7638        persisted_banned.into_iter().collect()
7639    } else {
7640        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
7641        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
7642        gapped |= g;
7643        match hi {
7644            Some(hi) => {
7645                let head = banlist_authored[hi];
7646                let ah = head.author.to_hex();
7647                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
7648                    .unwrap_or_default()
7649                    .into_iter()
7650                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
7651                    .take(super::roles::MAX_BANLIST)
7652                    .collect();
7653                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
7654                banlist_persist = Some((list.clone(), head.version));
7655                list.into_iter().collect()
7656            }
7657            None => persisted_banned.into_iter().collect(),
7658        }
7659    };
7660
7661    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
7662    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
7663    // the banned set excluded so a banned admin loses authority.
7664    UNCITED_ACCEPTED.with(|n| *n.borrow_mut() = 0);
7665    let mut refusals: std::collections::BTreeMap<String, &'static str> = Default::default();
7666    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned, Some(&mut refusals));
7667    AUTHORITY_REFUSALS.with(|c| *c.borrow_mut() = refusals);
7668    if let Some(bh) = banlist_head {
7669        heads.push(bh);
7670    }
7671
7672    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
7673    // authorization, so an attacker's unauthorized roles can't consume cap slots and
7674    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
7675    // roles empty the roster).
7676    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
7677        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
7678        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
7679        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
7680        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
7681        authorized.grants.retain(|g| !g.role_ids.is_empty());
7682    }
7683
7684    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
7685}
7686
7687/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
7688/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
7689struct AuthorityCand {
7690    role: Option<crate::community::roles::Role>,
7691    grant: Option<crate::community::roles::MemberGrant>,
7692    author: PublicKey,
7693    head: FoldedHead,
7694    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
7695    /// (supreme, cites nothing) or an uncited one — the latter is refused.
7696    citation: Option<crate::community::edition::AuthorityCitation>,
7697}
7698
7699/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
7700///
7701/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
7702/// pass that establishes those heads, so an external floor would refuse every
7703/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
7704/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
7705/// authority-verified head as its baseline, a tracking client fails closed per
7706/// entity — applied to the citation instead of the chain link.
7707fn citation_ok_in_fold(
7708    cid: &crate::community::CommunityId,
7709    heads: &[FoldedHead],
7710    owner_hex: Option<&str>,
7711    author: &PublicKey,
7712    citation: Option<&crate::community::edition::AuthorityCitation>,
7713) -> bool {
7714    let actor_hex = author.to_hex();
7715    if owner_hex == Some(actor_hex.as_str()) {
7716        return true;
7717    }
7718    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
7719    let as_entity: Vec<crate::community::roster::EntityHead> = heads
7720        .iter()
7721        .map(|h| crate::community::roster::EntityHead {
7722            entity_hex: h.entity_hex.clone(),
7723            version: h.version,
7724            self_hash: h.self_hash,
7725            inner_id: h.inner_id,
7726            citation: None,
7727        })
7728        .collect();
7729    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
7730}
7731
7732thread_local! {
7733    /// How many uncited editions the last fold admitted on rank alone — pre-citation
7734    /// clients' output. Visibility only; the rank gates decide.
7735    static UNCITED_ACCEPTED: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };
7736    /// For a grant refused on a missing role reference: which role ids were absent.
7737    static MISSING_ROLE_REFS: std::cell::RefCell<std::collections::BTreeMap<String, String>> =
7738        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7739    /// Refusal reasons from the most recent authority fold on this thread — read by
7740    /// the roster diagnostic. Diagnostic only; nothing branches on it.
7741    static AUTHORITY_REFUSALS: std::cell::RefCell<std::collections::BTreeMap<String, &'static str>> =
7742        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
7743}
7744
7745/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
7746/// takes the highest-version candidate whose author is authorized to author it under
7747/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
7748/// vanishing the entity. Authority resolves outward from the owner (proven by
7749/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
7750/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
7751/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
7752/// advances only to authorized heads — an unauthorized forgery never poisons it).
7753fn select_authorized(
7754    cid: &crate::community::CommunityId,
7755    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7756    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
7757    owner_hex: Option<&str>,
7758    excluded: &std::collections::BTreeSet<String>,
7759    // Why each entity was refused, for diagnosis. A rejection here is invisible by
7760    // construction — every gate is a bare `continue` — so a roster that silently
7761    // collapses gives a reader nothing to go on.
7762    mut why: Option<&mut std::collections::BTreeMap<String, &'static str>>,
7763) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
7764    use crate::community::roles::{CommunityRoles, Permissions};
7765    let mut accepted = CommunityRoles::default();
7766    let mut heads: Vec<FoldedHead> = Vec::new();
7767    // Jacobi iteration: authority propagates one delegation level per round, so a
7768    // generous multiple of the entity count is an ample bound. Non-convergence (never
7769    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
7770    // are ever selected.
7771    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
7772    for _ in 0..bound {
7773        let mut next = CommunityRoles::default();
7774        let mut next_heads: Vec<FoldedHead> = Vec::new();
7775
7776        for cands in role_cands.values() {
7777            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
7778            // is necessary but not sufficient: an edition REPLACES the entity, so
7779            // the author must also outrank the position standing before it.
7780            // Without that, an admin at position 5 rewrites the position-1 role
7781            // to position 9 — every check passes, since 9 is beneath them — and
7782            // a role that outranked them is now beneath them, along with everyone
7783            // holding it. Rank inversion by republish.
7784            //
7785            // The chain is replayed ASCENDING so each version is judged against
7786            // the position its own predecessor established, then the highest
7787            // admissible version wins (candidates arrive version-DESC, forks
7788            // broken by lowest inner_id — preserved by walking version groups).
7789            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
7790            let mut standing: Option<u32> = None;
7791            let mut i = cands.len();
7792            while i > 0 {
7793                let hi = i;
7794                let ver = cands[i - 1].head.version;
7795                while i > 0 && cands[i - 1].head.version == ver {
7796                    i -= 1;
7797                }
7798                // One winner per version: fork siblings can't sidestep the gate.
7799                for c in cands[i..hi].iter().rev() {
7800                    let Some(role) = &c.role else { continue };
7801                    let ah = c.author.to_hex();
7802                    let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7803                        if let Some(m) = w.as_mut() {
7804                            m.insert(c.head.entity_hex.clone(), r);
7805                        }
7806                    };
7807                    if excluded.contains(&ah) || role.position == 0 {
7808                        note(&mut why, "role: author banned or position 0");
7809                        continue;
7810                    }
7811                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
7812                        note(&mut why, "role: author lacks MANAGE_ROLES over this position");
7813                        continue;
7814                    }
7815                    if let Some(prev) = standing {
7816                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
7817                            note(&mut why, "role: author cannot outrank the position it replaces");
7818                            continue;
7819                        }
7820                    }
7821                    // A `vac` is a SYNC FLOOR, not the verdict (CORD-04 §5): a verifier
7822                    // waits until it has synced the cited Grant, then resolves rank
7823                    // against its CURRENT roster. So a citation we cannot resolve still
7824                    // parks — that is a real sync gap. But an edition carrying NO
7825                    // citation declared no floor to wait for, and refusing it converts a
7826                    // sync aid into an authority requirement the spec says it is not.
7827                    //
7828                    // This cannot escalate: every rank gate above has already passed —
7829                    // the author is authorized under the roster resolved so far and
7830                    // strictly outranks both this position and the one it replaces. The
7831                    // citation never granted authority; it only decided when to look.
7832                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7833                        if c.citation.is_some() {
7834                            note(&mut why, "role: cited a grant we cannot resolve");
7835                            continue;
7836                        }
7837                        UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7838                    }
7839                    admissible.insert(c.head.self_hash);
7840                    standing = Some(role.position);
7841                    break;
7842                }
7843            }
7844            for c in cands {
7845                let Some(role) = &c.role else { continue };
7846                if !admissible.contains(&c.head.self_hash) {
7847                    continue;
7848                }
7849                next.roles.push(role.clone());
7850                next_heads.push(c.head.clone());
7851                break; // highest admissible candidate for this entity
7852            }
7853        }
7854        for cands in grant_cands.values() {
7855            for c in cands {
7856                let c2 = c;
7857                let Some(grant) = &c.grant else { continue };
7858                let ah = c.author.to_hex();
7859                let note = |w: &mut Option<&mut std::collections::BTreeMap<String, &'static str>>, r: &'static str| {
7860                    if let Some(m) = w.as_mut() {
7861                        m.insert(c.head.entity_hex.clone(), r);
7862                    }
7863                };
7864                if excluded.contains(&ah) || excluded.contains(&grant.member) {
7865                    note(&mut why, "grant: author or member banned");
7866                    continue;
7867                }
7868                // The granter must outrank every granted role (resolved against the
7869                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
7870                //
7871                // Resolve the roles that CAN be resolved rather than discarding the
7872                // grant whole. All-or-nothing deadlocks a community that grew the
7873                // ordinary way: an admin creates a role, the owner grants that role to
7874                // them, and now the grant needs the role accepted while the role needs
7875                // the grant. Neither can go first, the fixpoint lands empty, and the
7876                // entire roster — the owner's own grants included — collapses. Seen on
7877                // two live communities; the cached roster was all that hid it.
7878                //
7879                // Partial resolution is strictly fail-closed: it can only ever grant
7880                // FEWER roles than the edition names, every retained role is still
7881                // rank-checked below, and the unresolved ones fold in on a later
7882                // iteration once their own authority settles. Nothing is granted that
7883                // the author did not write.
7884                let resolved: Vec<(String, u32)> = grant
7885                    .role_ids
7886                    .iter()
7887                    .filter_map(|rid| accepted.role(rid).map(|r| (rid.clone(), r.position)))
7888                    .collect();
7889                let positions: Vec<u32> = resolved.iter().map(|(_, p)| *p).collect();
7890                if resolved.is_empty() && !grant.role_ids.is_empty() {
7891                    // Record WHICH role is missing — a grant refused for pointing at
7892                    // an unaccepted role is the head of a dependency chain, and the
7893                    // chain is unreadable without naming its next link.
7894                    if let Some(m) = why.as_mut() {
7895                        let missing: Vec<String> = grant
7896                            .role_ids
7897                            .iter()
7898                            .filter(|rid| accepted.role(rid).is_none())
7899                            .map(|rid| rid[..8.min(rid.len())].to_string())
7900                            .collect();
7901                        MISSING_ROLE_REFS.with(|c| {
7902                            c.borrow_mut().insert(c2.head.entity_hex.clone(), missing.join("+"));
7903                        });
7904                        m.insert(c2.head.entity_hex.clone(), "grant: references a role not in the accepted roster");
7905                    }
7906                    continue;
7907                }
7908                // An unresolvable citation parks; an absent one is tolerated ONLY when
7909                // the edition grants something. The rank gate below is what stops
7910                // escalation, and it needs a position to check: a REVOCATION carries no
7911                // role ids, so `positions.iter().all(..)` is vacuously true and the
7912                // citation is the only thing standing there. Without this an
7913                // unauthorized stranger strips any member's roles with a forged empty
7914                // grant — proven by `a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version`.
7915                //
7916                // So: an uncited edition may ADD authority (fully rank-checked) but
7917                // never REMOVE it.
7918                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
7919                    if c.citation.is_some() {
7920                        note(&mut why, "grant: cited a grant we cannot resolve");
7921                        continue;
7922                    }
7923                    if resolved.is_empty() {
7924                        note(&mut why, "grant: UNCITED revocation — a strip must cite its authority");
7925                        continue;
7926                    }
7927                    UNCITED_ACCEPTED.with(|n| *n.borrow_mut() += 1);
7928                }
7929                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
7930                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
7931                {
7932                    // Record the head even for an EMPTY grant (a revoke is a real chain
7933                    // advance a completeness check must see), but don't carry the husk
7934                    // into the roster.
7935                    next_heads.push(c.head.clone());
7936                    if !resolved.is_empty() {
7937                        // Carry only the resolved subset; the rest arrive on a later
7938                        // iteration, or never, if they were never legitimate.
7939                        let mut g = grant.clone();
7940                        g.role_ids = resolved.iter().map(|(rid, _)| rid.clone()).collect();
7941                        next.grants.push(g);
7942                    }
7943                    break;
7944                }
7945            }
7946        }
7947
7948        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
7949        accepted = next;
7950        heads = next_heads;
7951        if converged {
7952            break;
7953        }
7954    }
7955    (accepted, heads)
7956}
7957
7958/// Apply a folded community-metadata head. Relays only overwrite when the edition
7959/// carries a non-empty list (a metadata edition that omits relays must not blank
7960/// the working set). Returns whether anything changed.
7961fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
7962    let mut changed = false;
7963    if out.name != meta.name {
7964        out.name = meta.name;
7965        changed = true;
7966    }
7967    if out.description != meta.description {
7968        out.description = meta.description;
7969        changed = true;
7970    }
7971    // Icon/banner apply verbatim, None included — an edition is the full
7972    // document, so an absent image IS a removal (editors preserve via
7973    // `CommunityV2::metadata()`).
7974    if out.icon != meta.icon {
7975        out.icon = meta.icon;
7976        changed = true;
7977    }
7978    if out.banner != meta.banner {
7979        out.banner = meta.banner;
7980        changed = true;
7981    }
7982    // Client-extensible + unknown fields ride the fold verbatim so our own
7983    // editions can carry them forward (CORD-02 §6).
7984    if out.meta_custom != meta.custom {
7985        out.meta_custom = meta.custom;
7986        changed = true;
7987    }
7988    if out.meta_extra != meta.extra {
7989        out.meta_extra = meta.extra;
7990        changed = true;
7991    }
7992    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
7993    // other construction boundary, and the fold is a boundary like any other: an
7994    // authorized editor is not a trusted one, and an oversize list costs every
7995    // member a fan-out on each publish and the slowest of N on each fetch
7996    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
7997    // CAPPED list too — against the raw one, an oversize edition never compares
7998    // equal, so every fold would report a change and re-save forever.
7999    let relays = crate::community::cap_relays(meta.relays);
8000    if !relays.is_empty() && out.relays != relays {
8001        out.relays = relays;
8002        changed = true;
8003    }
8004    changed
8005}
8006
8007/// Apply a folded channel-metadata head: delete removes the channel, a rename
8008/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
8009/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
8010/// rekey plane or a fresh bundle). Returns whether anything changed.
8011fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
8012    let deleted = meta.deleted.unwrap_or(false);
8013    if deleted {
8014        let before = out.channels.len();
8015        out.channels.retain(|c| c.id.0 != id.0);
8016        return out.channels.len() != before;
8017    }
8018    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
8019        Some(existing) => {
8020            let mut changed = false;
8021            if existing.name != meta.name {
8022                existing.name = meta.name;
8023                changed = true;
8024            }
8025            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
8026            // local edit republishes them instead of wiping (CORD-02 §6).
8027            if existing.voice != meta.voice {
8028                existing.voice = meta.voice;
8029                changed = true;
8030            }
8031            if existing.meta_custom != meta.custom {
8032                existing.meta_custom = meta.custom;
8033                changed = true;
8034            }
8035            if existing.meta_extra != meta.extra {
8036                existing.meta_extra = meta.extra;
8037                changed = true;
8038            }
8039            // The owner's edition authoritatively declares visibility. A channel the
8040            // owner marks PUBLIC must derive from the root (key = None) — this heals a
8041            // bundle-time misclassification where an attacker set a public channel's
8042            // grant key to their own, silently addressing it at a plane only they read.
8043            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
8044            // record stays public) until the convert flow (key mint + cursor rebase
8045            // to the conversion's channel epoch) lands — the send side refuses to
8046            // publish one, and a foreign client's conversion won't move us.
8047            if !meta.private && (existing.private || existing.key.is_some()) {
8048                existing.private = false;
8049                existing.key = None;
8050                changed = true;
8051            }
8052            changed
8053        }
8054        None if !meta.private => {
8055            // A public channel derives its Chat Plane from the community_root at the
8056            // current root epoch (key = None); its stored epoch mirrors the root.
8057            out.channels.push(ChannelV2 {
8058                id,
8059                name: meta.name,
8060                private: false,
8061                key: None,
8062                epoch: out.root_epoch,
8063                voice: meta.voice,
8064                meta_custom: meta.custom,
8065                meta_extra: meta.extra,
8066            });
8067            true
8068        }
8069        None => {
8070            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
8071            // generation — CORD-03 §2 numbers the first private key epoch 1). The
8072            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
8073            // delivers a key, every read/send/subscribe path skips the channel; the
8074            // root-fallback in `channel_secret` is never taken for it.
8075            out.channels.push(ChannelV2 {
8076                id,
8077                name: meta.name,
8078                private: true,
8079                key: None,
8080                epoch: Epoch(0),
8081                voice: meta.voice,
8082                meta_custom: meta.custom,
8083                meta_extra: meta.extra,
8084            });
8085            true
8086        }
8087    }
8088}
8089
8090// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
8091
8092/// The outcome of a rekey-follow pass.
8093pub struct RekeyFollow {
8094    /// The community after adopting every rotation it could catch up on, or `None`
8095    /// if nothing advanced.
8096    pub updated: Option<CommunityV2>,
8097    /// A base rotation removed us — the caller tears the local hold down (the
8098    /// updated community is not persisted in that case).
8099    pub self_removed: bool,
8100    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
8101    /// flag is already set; the caller surfaces the death and stops following.
8102    pub dissolved: bool,
8103    /// A complete authorized rotation carried a blob AT OUR LOCATOR that would
8104    /// not open (a width this build predates, a rotator bug, or a deliberately
8105    /// undecryptable blob planted to pass an admissibility check while severing
8106    /// us). Never a removal (CORD-06 §2: removal is the ABSENCE of a blob), so
8107    /// the walk parks — but the park is otherwise indistinguishable from "no
8108    /// rotation happened", which is exactly what makes it abusable. Surfaced so
8109    /// the user learns their key delivery is broken while the prior root still
8110    /// reaches everyone.
8111    pub wedged: bool,
8112}
8113
8114/// The most archived base roots a channel-rekey lookup fans across per step. A
8115/// standalone rekey rides the minter's then-current root and a removal's rides the
8116/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
8117/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
8118/// an older root than the fan reaches) only heals via a fresh invite bundle — the
8119/// walk is strictly sequential, so a later rotation can't be reached either.
8120const MAX_ADDRESSING_ROOTS: usize = 8;
8121
8122/// The base roots a channel rekey may be addressed under, freshest first: the
8123/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
8124/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
8125/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
8126/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
8127/// fetch addresses but auth never registered is invisible on an AUTH-gating
8128/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
8129/// wedges at its old epoch while the base advances.
8130pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
8131    let mut roots: Vec<[u8; 32]> = vec![cur_root];
8132    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
8133        .unwrap_or_default();
8134    archived.sort_by_key(|a| std::cmp::Reverse(a.0 .0));
8135    for (_, r) in archived {
8136        if !roots.contains(&r) {
8137            roots.push(r);
8138        }
8139    }
8140    roots.truncate(MAX_ADDRESSING_ROOTS);
8141    roots
8142}
8143
8144/// Follow rekeys for a held community: advance the base (root) epoch and each
8145/// Private channel's epoch as far as authorized rotations allow, adopting the
8146/// fresh key we're still a recipient of at each step and dropping a scope we've
8147/// been removed from. Persists the result. Called when a rekey wrap arrives in
8148/// realtime so a long-running bot keeps decrypting after a rotation instead of
8149/// going silent.
8150///
8151/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
8152/// or any `BAN` holder, and a CHANNEL rotation from the owner or a
8153/// `MANAGE_CHANNELS` holder — both under the PERSISTED roster (folded by
8154/// `follow_control`), minus the banlist, so an admin-created private channel
8155/// keys up on every member. Concluding OUR OWN removal takes more than the bit:
8156/// the rotator must strictly outrank us (`*_outranks_me`), so an equal-rank
8157/// admin can never evict a peer by minting a rotation that skips their blob.
8158///
8159/// **Addressing fans across held base roots:** each channel step queries its
8160/// next-epoch rekey address under the current root AND the archived prior roots,
8161/// so a base adopt landing before a Refounding's prior-root-addressed channel
8162/// rekeys (or before a creation delivery minted under an older root) can't
8163/// strand the channel.
8164///
8165/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
8166/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
8167/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
8168/// every follower converges. An incomplete rotation (a missing chunk) never
8169/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
8170/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
8171/// §2: "a convergence check, not a secrecy mechanism") — authority is its
8172/// boundary; its epoch is the scan cursor, advancing past complete rotations
8173/// that exclude us so the walk converges on the channel's current epoch.
8174/// Explain this client's epoch state: what we hold, what the next-epoch base plane
8175/// offers, and — per rotation found there — exactly which `advance_scope` gate
8176/// decided we did or did not adopt it.
8177///
8178/// **Ships in release builds deliberately.** A strand is silent by construction: a
8179/// client parked on a dead epoch is subscribed at its own addresses and simply sees
8180/// nothing, which is indistinguishable from a quiet community. Answering "why is
8181/// this client stuck" without this meant reading sqlite by hand. It is also the
8182/// instrument for comparing two clients' views of one community — the local half
8183/// (`held`) is what diverges first.
8184///
8185/// Read-only, and safe to surface: every rotator/owner is a PUBLIC key, and no key
8186/// material, blob plaintext or roster secret is returned.
8187pub async fn explain_epoch_state<T: Transport + ?Sized>(
8188    transport: &T,
8189    community: &CommunityV2,
8190) -> Result<serde_json::Value, String> {
8191    let my_xonly = me_pk()?.to_bytes();
8192    let owner = community.owner()?;
8193    let owner_hex = owner.to_hex();
8194    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8195    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8196    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8197    let held_epoch = community.root_epoch;
8198    let held_key = community.community_root;
8199    let next = Epoch(held_epoch.0.saturating_add(1));
8200    let group = base_rekey_group_key(&held_key, community.id(), next);
8201    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
8202    let rotations = rekey::collect_rotations(&chunks);
8203
8204    let reports: Vec<serde_json::Value> = rotations
8205        .iter()
8206        .map(|r| {
8207            let rotator_is_owner = r.rotator == owner;
8208            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
8209            // Roster, not owner-identity — report that gate, not just owner-equality.
8210            let rotator_authorized = rotator_is_owner
8211                || (!banned.contains(&r.rotator.to_hex())
8212                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
8213            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
8214            let epoch_ok = r.new_epoch.0 == next.0;
8215            let complete = r.is_complete();
8216            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
8217            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
8218            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
8219            // is a takeover attempt — this tells whether an "owner must be kept"
8220            // adopt-block would be safe here (it would falsely reject a legitimate
8221            // rotation that happened to exclude the owner).
8222            let owner_kept = r.rotator == owner
8223                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
8224            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
8225            let verdict = if !rotator_authorized {
8226                "REJECTED: rotator holds no BAN authority in the folded roster"
8227            } else if !scope_ok {
8228                "REJECTED: scope is not Root"
8229            } else if !epoch_ok {
8230                "REJECTED: new_epoch != held+1"
8231            } else if !complete {
8232                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
8233            } else if continuity != "Extends" {
8234                "REJECTED: continuity does not extend my held root (FORK/GAP)"
8235            } else if has_my_blob {
8236                "ADOPT: authorized + complete + continuous + my blob present"
8237            } else {
8238                "REMOVED: complete authorized rotation with no blob for me"
8239            };
8240            serde_json::json!({
8241                "rotator": r.rotator.to_hex(),
8242                "rotator_is_recorded_owner": rotator_is_owner,
8243                "rotator_authorized_ban": rotator_authorized,
8244                "scope_is_root": scope_ok,
8245                "new_epoch": r.new_epoch.0,
8246                "prev_epoch": r.prev_epoch.0,
8247                "declared_chunks": r.declared_chunks,
8248                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
8249                "is_complete": complete,
8250                "continuity_vs_held_root": continuity,
8251                "my_blob_present": has_my_blob,
8252                "owner_kept": owner_kept,
8253                "blob_count": r.blobs.len(),
8254                "severing": r.severed,
8255                "verdict": verdict,
8256            })
8257        })
8258        .collect();
8259
8260    // The LOCAL half. Two clients on one community diverge here first, and every
8261    // field is a thing that has silently frozen a client in the field: the roster
8262    // is what authorizes a rotator, its provenance is what the freeze-guard reads,
8263    // and the invite registry is what Public/Private hangs off.
8264    let roster_at = crate::db::community::get_community_roles_at(&cid_hex).unwrap_or(0);
8265    let admin_count = roster.grants.iter().filter(|g| roster.is_admin(&g.member)).count();
8266    let held = serde_json::json!({
8267        "root_epoch": held_epoch.0,
8268        "roster_roles": roster.roles.len(),
8269        "roster_grants": roster.grants.len(),
8270        "roster_admins": admin_count,
8271        // 0 = a locally-seeded roster that never folded from the plane. A client
8272        // whose roster never gains provenance can never authorize a non-owner
8273        // admin, so it refuses their rotations forever.
8274        "roster_provenance_at": roster_at,
8275        "banlist_entries": banned.len(),
8276        "sever_marks": sever_marks(&cid_hex).keys().copied().collect::<Vec<_>>(),
8277        "control_plane_split": community.control_pk.is_some(),
8278        "private_channels": community.channels.iter().filter(|c| c.private).count(),
8279        "channels_awaiting_key": community.channels.iter().filter(|c| c.private && c.key.is_none()).count(),
8280    });
8281
8282    // The one-line answer. `probing_next_epoch` existing at all proves the community
8283    // moved without us; what we did about it is the `rotations` verdicts.
8284    let summary = if rotations.is_empty() {
8285        "UP TO DATE (or the plane is unreachable): no rotation offered at held+1"
8286    } else if reports.iter().any(|r| r["verdict"].as_str().is_some_and(|v| v.starts_with("ADOPT"))) {
8287        "BEHIND: an adoptable rotation is waiting — run a sync"
8288    } else {
8289        "WEDGED: a rotation exists at held+1 and every one was refused (see verdicts)"
8290    };
8291
8292    Ok(serde_json::json!({
8293        "summary": summary,
8294        "recorded_owner": owner.to_hex(),
8295        "held": held,
8296        "held_root_epoch": held_epoch.0,
8297        "probing_next_epoch": next.0,
8298        "base_plane_pk": group.pk_hex(),
8299        "raw_chunks_parsed": chunks.len(),
8300        "rotations_found": rotations.len(),
8301        "rotations": reports,
8302    }))
8303}
8304
8305pub async fn follow_rekeys<T: Transport + ?Sized>(
8306    transport: &T,
8307    community: &CommunityV2,
8308    session: &std::sync::Arc<crate::db::Session>,
8309) -> Result<RekeyFollow, String> {
8310    crate::db::scoped(async move {
8311        // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
8312        // past its tombstone — don't adopt a rotation into a grave.
8313        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8314        if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
8315            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
8316        }
8317        // An offline member must also LEARN of a death: the tombstone rides its own
8318        // public plane, which the live sub watches but no catch-up fetch touched —
8319        // without this, a member who slept through a dissolution follows (and posts
8320        // into) a grave forever. Fail-open on transport failure: availability is
8321        // never death.
8322        if is_dissolved(transport, community).await {
8323            if session.is_live() {
8324                let _ = crate::db::community::set_community_dissolved(&cid_hex);
8325            }
8326            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true, wedged: false });
8327        }
8328        let signer = crate::signer::active_signer()?;
8329        let my_pk = me_pk()?;
8330        let my_xonly = my_pk.to_bytes();
8331        let owner = community.owner()?;
8332        let owner_hex = owner.to_hex();
8333        let mut cur = community.clone();
8334        let mut changed = false;
8335        // A rotation delivered us a blob we could not open (see RekeyFollow::wedged).
8336        let mut wedged = false;
8337
8338        // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
8339        // follow_control; the worker folds control right after this rekey pass). This
8340        // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
8341        // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
8342        // the base-admissibility protected-set: a superior whose grant this receiver
8343        // has not yet folded is not in `roster.grants`, so a non-owner Refounding
8344        // excluding them can be adopted within that propagation window. Bounded — the
8345        // owner is ALWAYS hard-protected below (independent of the roster) and can
8346        // counter-refound; and it is inherent to eventual consistency (one cannot gate
8347        // on a grant never seen). Tightening this (fold control before the first rekey,
8348        // or gate non-owner adoption on roster freshness) is a follow-on.
8349        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
8350        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
8351        let me_hex = my_pk.to_hex();
8352        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
8353        // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
8354        // never honored by a lagging client." Persisted heads ARE the right floor
8355        // here (unlike the roster fold, which must resolve in-pass): a rotation is
8356        // judged against a roster we already folded, and `follow_control` — v2's only
8357        // roster writer — persists the heads in the same pass it writes the roster.
8358        // A joiner who sees a rotation before folding control simply parks it and
8359        // heals on the next follow, which runs control first.
8360        let cited_ok = |rot: &rekey::Rotation| -> bool {
8361            citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
8362        };
8363        let channel_rotator_ok = |rotator: &PublicKey| -> bool {
8364            if *rotator == owner {
8365                return true;
8366            }
8367            let rh = rotator.to_hex();
8368            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
8369        };
8370        // Concluding MY removal takes more than the bit: the rotator must strictly
8371        // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
8372        // removed target"), so an equal-rank admin can never silently evict a peer
8373        // (or the owner) by minting a complete rotation that skips their blob.
8374        let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8375            if *rotator == owner {
8376                return true;
8377            }
8378            let rh = rotator.to_hex();
8379            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
8380        };
8381        // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
8382        // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
8383        // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
8384        // owner is always authorized. (Owner-only here silently wedged every member
8385        // whose community was refounded by a non-owner admin.)
8386        let base_rotator_ok = |rotator: &PublicKey| -> bool {
8387            if *rotator == owner {
8388                return true;
8389            }
8390            let rh = rotator.to_hex();
8391            !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
8392        };
8393        // Concluding MY removal via a base rotation takes more than the bit: the
8394        // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
8395        // Rotator must strictly outrank every removed target"), so an equal-rank
8396        // admin can never evict a peer (or the owner) by minting a rotation that
8397        // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
8398        let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
8399            if *rotator == owner {
8400                return true;
8401            }
8402            let rh = rotator.to_hex();
8403            !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
8404        };
8405
8406                // A non-owner Refounding may only remove members the rotator strictly
8407        // OUTRANKS. The protected set is the owner plus every grant-holder the
8408        // rotator can't act on with BAN (a peer or superior) — excluding one is
8409        // an authority-escalation takeover, so its rotation is inadmissible.
8410        // Plain members hold no grant and are always outranked by a BAN-holder,
8411        // so removing them is legitimate and needs no memberlist.
8412        let base_admissible = |r: &rekey::Rotation| -> bool {
8413            if r.rotator == owner {
8414                return true; // the owner is supreme.
8415            }
8416            // Uncited (or citing a Grant we haven't synced) → skip entirely:
8417            // neither adopt nor conclude a removal, exactly like an
8418            // unauthorized rotation. It parks and heals on the next follow.
8419            if !cited_ok(r) {
8420                return false;
8421            }
8422            let rotator_hex = r.rotator.to_hex();
8423            let has_blob = |xonly: &[u8; 32]| {
8424                rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
8425            };
8426            // The owner is never a valid removed target.
8427            if !has_blob(&owner.to_bytes()) {
8428                return false;
8429            }
8430            for g in &roster.grants {
8431                if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
8432                    continue; // self, owner (checked), or an already-authorized removal.
8433                }
8434                // A grant-holder the rotator can't act on is a peer/superior.
8435                if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
8436                    if let Ok(pk) = PublicKey::from_hex(&g.member) {
8437                        if !has_blob(&pk.to_bytes()) {
8438                            return false; // a peer/superior was excluded.
8439                        }
8440                    }
8441                }
8442            }
8443            true
8444        };
8445
8446        // ── Same-epoch convergence, BEFORE walking forward (CORD-06 §3) ─────────
8447        //
8448        // Two authorized rotators can mint the same epoch concurrently. The spec's
8449        // tiebreak is deterministic — the lexicographically lowest new base key wins
8450        // — but it was only ever applied among rotations seen in ONE fetch. A client
8451        // that saw only the higher sibling adopted it and then looked exclusively
8452        // for its successor, so the lower sibling became permanently unreachable:
8453        // two halves of a community, both at the same epoch number, different keys,
8454        // unable to read each other, forever. Nobody was misbehaving.
8455        //
8456        // So re-check the epoch we ALREADY hold on every follow, and step sideways
8457        // onto a strictly lower sibling. Down-only, exactly as the spec requires: a
8458        // held epoch converges only downward, so a flaky fetch that returns just the
8459        // higher sibling can never re-fork a settled epoch, and the walk terminates
8460        // because each convergence strictly decreases the held key.
8461        //
8462        // The losing branch's key is simply dropped. Messages already ingested from
8463        // it stay readable — they are stored decrypted under the account vault, not
8464        // re-derived from the epoch key — so converging costs only the fork-window
8465        // messages this client never fetched, which live on a branch nobody reads.
8466        if cur.root_epoch.0 > 0 {
8467            let prior = crate::db::community::held_epoch_key(
8468                &cid_hex,
8469                crate::community::SERVER_ROOT_SCOPE_HEX,
8470                cur.root_epoch.0 - 1,
8471            )
8472            .ok()
8473            .flatten();
8474            if let Some(prior_root) = prior {
8475                let group = base_rekey_group_key(&prior_root, cur.id(), cur.root_epoch);
8476                match fetch_rekey_chunks(transport, &cur.relays, &group).await {
8477                    Ok(siblings) if !siblings.is_empty() => {
8478                        let held_before = cur.community_root;
8479                        match advance_scope(
8480                            &[(siblings, Some((Epoch(cur.root_epoch.0 - 1), prior_root)))],
8481                            RekeyScope::Root,
8482                            cur.id(),
8483                            &base_rotator_ok,
8484                            &base_rotator_outranks_me,
8485                            &base_admissible,
8486                            &signer,
8487                            &my_xonly,
8488                            cur.root_epoch,
8489                        )
8490                        .await
8491                        {
8492                            // Strictly lower only. Equal = the branch we already hold.
8493                            Advance::Adopt { new_key, control_pk, control_root, .. } if new_key < held_before => {
8494                                crate::log_warn!(
8495                                    "[v2:follow {}] same-epoch fork at epoch {} — converging DOWN onto the canonical sibling",
8496                                    &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8497                                );
8498                                cur.community_root = new_key;
8499                                cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8500                                cur.control_root = control_root;
8501                                if let Err(e) = crate::db::community::store_epoch_key(
8502                                    &cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, cur.root_epoch.0, &new_key,
8503                                ) {
8504                                    crate::log_warn!("v2: converged epoch-key archive failed: {e}");
8505                                }
8506                                if matches!(crate::db::community::community_protocol(community.id()), Ok(Some(_))) {
8507                                    let _ = crate::db::community::save_community_v2(&cur);
8508                                }
8509                                changed = true;
8510                            }
8511                            // A sibling we cannot open is NOT a removal: refusing to
8512                            // converge parks us on a branch that still works, where
8513                            // concluding removal here would delete the community over
8514                            // a fork we merely could not read.
8515                            _ => {}
8516                        }
8517                    }
8518                    Ok(_) => {}
8519                    Err(e) => crate::log_warn!(
8520                        "[v2:follow {}] same-epoch fork check failed (will retry): {}",
8521                        &cid_hex[..8.min(cid_hex.len())], e
8522                    ),
8523                }
8524            }
8525        }
8526
8527        // Bound the catch-up: each real step consumes a valid authorized rotation, so a
8528        // finite chain terminates naturally; the cap defends against a relay feeding a
8529        // pathological set.
8530        const MAX_STEPS: usize = MAX_WALK_STEPS;
8531        // Wide enough to collapse a large community's channel fan into one wave,
8532        // narrow enough that a catch-up does not present as a burst to every relay.
8533        const CHANNEL_FAN_CONCURRENCY: usize = 8;
8534        // Containment hops adopted in this walk (B2: any one of them condemns —
8535        // a later ordinary hop in the same walk must not relaunder links open).
8536        let mut severed_hops: Vec<u64> = Vec::new();
8537        for _ in 0..MAX_STEPS {
8538            let mut advanced = false;
8539
8540            // The roots a channel rekey may be addressed under (re-read each pass —
8541            // a base adopt below changes the head, and its predecessor is already
8542            // archived). Shared with streamauth so the auth registration covers
8543            // exactly this fan.
8544            let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
8545
8546            // Private channels first: a removal-forced channel rekey rides the PRIOR
8547            // root (CORD-06 D2), so read channels before a base adopt moves it.
8548            let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
8549            // One concurrent wave per hop, not one round-trip per (channel, root).
8550            // Serially this was `private channels x addressing roots` fetches before a
8551            // single hop could be decided — around a hundred for a large community, and
8552            // a week's absence walks dozens of hops. The decisions below still run
8553            // serially and in the same order; only the reads overlap.
8554            let fan: Vec<(ChannelId, Option<[u8; 32]>, Epoch, Epoch)> = channel_ids
8555                .iter()
8556                .filter_map(|cid| {
8557                    cur.channel(cid).map(|ch| {
8558                        (*cid, ch.key, ch.epoch, Epoch(ch.epoch.0.saturating_add(1)))
8559                    })
8560                })
8561                .collect();
8562            let mut prefetched: std::collections::HashMap<([u8; 32], usize), Vec<rekey::RekeyChunk>> =
8563                std::collections::HashMap::new();
8564            {
8565                use futures_util::stream::StreamExt;
8566                // root #0 = current, #1.. = archived priors (indices only — root
8567                // bytes are key material and must never reach a log).
8568                let jobs: Vec<(ChannelId, usize, GroupKey, Epoch)> = fan
8569                    .iter()
8570                    .flat_map(|(cid, _, _, next)| {
8571                        addressing_roots.iter().enumerate().map(move |(ri, root)| {
8572                            (*cid, ri, channel_rekey_group_key(root, cid, *next), *next)
8573                        })
8574                    })
8575                    .collect();
8576                let relays = &cur.relays;
8577                let mut results = futures_util::stream::iter(jobs)
8578                    .map(|(cid, ri, group, next)| async move {
8579                        (cid, ri, next, fetch_rekey_chunks(transport, relays, &group).await)
8580                    })
8581                    .buffer_unordered(CHANNEL_FAN_CONCURRENCY);
8582                while let Some((cid, ri, next, out)) = results.next().await {
8583                    let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8584                    match out {
8585                        Ok(chunks) => {
8586                            if chunks.is_empty() {
8587                                continue;
8588                            }
8589                            crate::log_debug!(
8590                                "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
8591                                &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
8592                            );
8593                            prefetched.insert((cid.0, ri), chunks);
8594                        }
8595                        Err(e) => {
8596                            crate::log_warn!(
8597                                "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
8598                                &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
8599                            );
8600                            return Err(e);
8601                        }
8602                    }
8603                }
8604            }
8605
8606            for (cid, held_key, held_epoch, next) in fan {
8607                let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
8608                let mut batches: Vec<RekeyBatch> = Vec::new();
8609                for ri in 0..addressing_roots.len() {
8610                    if let Some(chunks) = prefetched.remove(&(cid.0, ri)) {
8611                        batches.push((chunks, held_key.map(|k| (held_epoch, k))));
8612                    }
8613                }
8614                // Keyless-adopt residual (documented, deferred hardening): a malicious
8615                // AUTHORIZED admin can fork a keyless member onto an orphan low-key
8616                // rotation nothing extends (keyed members' continuity filters it out).
8617                // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
8618                // exclude the member outright anyway, so the marginal harm is the wedge
8619                // outliving their demotion.
8620                match advance_scope(&batches, RekeyScope::Channel(cid), cur.id(), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
8621                    Advance::Adopt { new_key, .. } => {
8622                        if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8623                            ch.key = Some(new_key);
8624                            ch.epoch = next;
8625                        }
8626                        crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
8627                        // The adopter's own multi-epoch archive (the minter archived at
8628                        // mint) — this channel's history stays readable across rotations.
8629                        // fetch_channel compensates for the CURRENT epoch, so a failed
8630                        // archive only bites after the NEXT rotation — surface it.
8631                        if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&cid.0), next.0, &new_key) {
8632                            crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
8633                        }
8634                        advanced = true;
8635                        changed = true;
8636                    }
8637                    Advance::Removed => {
8638                        match held_key {
8639                            // A complete rotation dropped my blob — cut from the channel.
8640                            Some(_) => {
8641                                cur.channels.retain(|c| c.id.0 != cid.0);
8642                            }
8643                            // Keyless scan: this epoch's rotation completed without me.
8644                            // Advance the cursor so the walk converges on the channel's
8645                            // CURRENT epoch — my entry point is its next rotation (whose
8646                            // recipients are the members at that time) or a fresh bundle.
8647                            None => {
8648                                if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
8649                                    ch.epoch = next;
8650                                }
8651                            }
8652                        }
8653                        advanced = true;
8654                        changed = true;
8655                    }
8656                    Advance::Stay => {}
8657                    Advance::StayWedged => {
8658                        // A blob at our locator that will not open: park, never
8659                        // remove, and make the park legible (a silent park is
8660                        // indistinguishable from "no rotation happened").
8661                        wedged = true;
8662                    }
8663                }
8664            }
8665
8666            // Base rotation (Refounding): advances the root + root_epoch, re-addressing
8667            // every public channel, the guestbook, and the control plane by derivation
8668            // (refresh_subscription recomputes the author-set from the new root).
8669            {
8670                let held_epoch = cur.root_epoch;
8671                let held_key = cur.community_root;
8672                let next = Epoch(held_epoch.0.saturating_add(1));
8673                let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
8674                let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
8675                let batches = vec![(chunks, Some((held_epoch, held_key)))];
8676                match advance_scope(&batches, RekeyScope::Root, cur.id(), &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
8677                    Advance::Adopt { new_key, control_pk, control_root, severed, rotator } => {
8678                        // CORD-02 §5: this npub's snapshot — and only this npub's —
8679                        // seeds the new epoch's Guestbook. Recorded from the rotation
8680                        // this device just VERIFIED and adopted, so a snapshot can
8681                        // never nominate its own author.
8682                        record_epoch_minter(&cid_hex, next.0, &rotator.to_hex());
8683                        // Record the HOP that was severed, not the walk's final head:
8684                        // the mark drives both the link condemnation line and the
8685                        // rescue re-stamp, and a multi-step walk would otherwise
8686                        // present the containment at the wrong epoch.
8687                        if severed {
8688                            severed_hops.push(next.0);
8689                        }
8690                        cur.community_root = new_key;
8691                        cur.root_epoch = next;
8692                        // The control pair is the blob's, never inherited: the
8693                        // secret rolls with the root at every Refounding
8694                        // (CORD-02 §2), so a stale pair carried forward would sign
8695                        // (or subscribe) at a dead address.
8696                        //
8697                        // A legacy 72-byte blob therefore DOWNGRADES a split
8698                        // community back to the member-writable plane — the exact
8699                        // flooding surface the split closes. The width stays
8700                        // accepted (transition compat, CORD-06 §3), but once a
8701                        // community has been split this is either an outdated
8702                        // rotator or an attack, so it is never silent.
8703                        if cur.control_pk.is_some() && control_pk.is_none() {
8704                            crate::log_warn!(
8705                                "[v2:follow {}] rotation to epoch {} carried a LEGACY base blob — this community's control plane reverts to the member-writable address",
8706                                &cid_hex[..8.min(cid_hex.len())], next.0
8707                            );
8708                        }
8709                        cur.control_pk = control_pk.and_then(|pk| PublicKey::from_slice(&pk).ok());
8710                        cur.control_root = control_root;
8711                        // Archive on adopt: without this, a member who lived through TWO
8712                        // Refoundings loses the middle epoch's public history (only the
8713                        // minter archived it).
8714                        if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
8715                            crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
8716                        }
8717                        // CHECKPOINT THE HOP. A client back from a long absence walks
8718                        // many epochs in one pass, and the row used to be written only
8719                        // after the whole loop — so a fetch failure on the last hop
8720                        // discarded every hop already taken. The next attempt started
8721                        // from scratch, cost the same again, and on a flaky link could
8722                        // never converge at all. Banking here makes the walk resumable:
8723                        // progress is monotonic, and an interrupted catch-up picks up
8724                        // where it stopped.
8725                        //
8726                        // The FULL row, never just root+epoch: `control_pk`/
8727                        // `control_root` roll with the root, so a narrow write would
8728                        // leave the control pair an epoch behind and fold the control
8729                        // plane at a dead address. Archive first, then the head — a
8730                        // stray archive is harmless, a missing one is permanent.
8731                        //
8732                        // A leave/delete racing the walk must not be resurrected by an
8733                        // upsert, so re-check the row still exists before each write.
8734                        match crate::db::community::community_protocol(community.id()) {
8735                            Ok(Some(_)) => {
8736                                if let Err(e) = crate::db::community::save_community_v2(&cur) {
8737                                    crate::log_warn!("v2: hop checkpoint failed at epoch {} (the walk will redo it): {e}", next.0);
8738                                }
8739                            }
8740                            // Gone mid-walk: stop rather than resurrect it.
8741                            Ok(None) => return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged }),
8742                            Err(e) => crate::log_warn!("v2: hop checkpoint skipped at epoch {}: {e}", next.0),
8743                        }
8744                        advanced = true;
8745                        changed = true;
8746                    }
8747                    Advance::Removed => {
8748                        return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false, wedged: false });
8749                    }
8750                    Advance::Stay => {}
8751                    Advance::StayWedged => {
8752                        // A blob at our locator that will not open: park, never
8753                        // remove, and make the park legible (a silent park is
8754                        // indistinguishable from "no rotation happened").
8755                        wedged = true;
8756                    }
8757                }
8758            }
8759
8760            if !advanced {
8761                break;
8762            }
8763        }
8764
8765        if wedged {
8766            // Legible, and actionable: the prior root still reaches every member,
8767            // so an owner/admin seeing this can counter-rotate before the honest
8768            // chain moves on.
8769            crate::log_warn!(
8770                "[v2:follow {}] a complete rotation delivered a key blob this client cannot open — parked at epoch {} (never treated as a removal)",
8771                &cid_hex[..8.min(cid_hex.len())], cur.root_epoch.0
8772            );
8773            crate::emit_event(
8774                "community_rekey_wedged",
8775                &serde_json::json!({ "community_id": cid_hex, "held_epoch": cur.root_epoch.0 }),
8776            );
8777        }
8778        if !changed {
8779            // A pending severance whose revoke was interrupted must heal on QUIET
8780            // passes too — waiting for the next rotation means a one-shot transport
8781            // blip leaves the community reading Public (and every future ban
8782            // refound-less) until someone happens to rotate again.
8783            retry_pending_severance(transport, &cur).await;
8784            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8785        }
8786        // A leave/delete raced this follow: saving would resurrect the community row
8787        // (the save is an upsert) with no floor rows behind it.
8788        if crate::db::community::community_protocol(community.id())?.is_none() {
8789            return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false, wedged });
8790        }
8791        crate::db::community::save_community_v2(&cur)?;
8792        // Mark every containment hop FIRST: the marks are what condemn links, and
8793        // both the severance below and the refresh consult them — so the order of
8794        // those two stops mattering (a condemned link is skipped by the refresh
8795        // and buried by the severance, in either order, on any pass).
8796        for hop in &severed_hops {
8797            add_sever_mark(&cid_hex, *hop, now_ms() / 1000);
8798        }
8799        if !severed_hops.is_empty() {
8800            // Someone ELSE ran a Severing Refounding: my links minted below the
8801            // severed epoch are the raid's door — bury them instead of carrying
8802            // them forward. A fresh link minted at-or-after it survives every
8803            // device's adopt, however late (the epoch is the same on all of them).
8804            if let Err(e) = sever_own_links(transport, &cur).await {
8805                crate::log_warn!("[Sever] own-link revocation on adopt failed (retries next follow): {e}");
8806            }
8807        }
8808        // Carry my SURVIVING links across the rotation someone ELSE performed
8809        // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
8810        // their own — so without this every other creator's links keep vending the
8811        // superseded root and drop new joiners onto a dead epoch, which is exactly
8812        // the stranding the stable-URL refresh exists to prevent. Condemned links
8813        // are skipped inside (see the guard there). Best-effort and idempotent.
8814        let _ = refresh_public_links(transport, &cur).await;
8815        // A pending sever mark (mine or adopted) whose revoke never completed
8816        // keeps retrying while condemned links remain (B4).
8817        retry_pending_severance(transport, &cur).await;
8818        Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false, wedged })
8819    })
8820    .await
8821}
8822
8823/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
8824/// address.
8825enum Advance {
8826    /// Adopt this fresh key for `next_epoch`. A BASE adoption also carries the
8827    /// next epoch's Control Plane pair from the blob (CORD-06 §1) — the pk in
8828    /// every 104/136-byte blob, the secret only in a staff recipient's 136. The
8829    /// pair is the BLOB's, never inherited: a legacy 72-byte blob carries
8830    /// neither (that epoch's Control folds at the legacy address), and channel
8831    /// rotations never carry any.
8832    Adopt { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]>, severed: bool, rotator: PublicKey },
8833    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
8834    Removed,
8835    /// No owner rotation extends my held epoch (yet) — keep the current key.
8836    Stay,
8837    /// A complete authorized rotation carried a blob at MY locator that would
8838    /// not open. Never a removal (CORD-06 §2), but never silent either — the
8839    /// caller surfaces it (see [`RekeyFollow::wedged`]).
8840    StayWedged,
8841}
8842
8843/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
8844async fn fetch_rekey_chunks<T: Transport + ?Sized>(
8845    transport: &T,
8846    relays: &[String],
8847    group: &GroupKey,
8848) -> Result<Vec<rekey::RekeyChunk>, String> {
8849    // A rekey plane address is community_root-derived, so ANY member can seal junk
8850    // 3303s there — a flood (or, organically, a large community's own multi-chunk
8851    // rotation past the newest window) could bury the genuine owner/admin rotation
8852    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
8853    // control pager's discipline) so a buried authorized chunk is still recovered;
8854    // the seal + authority filter downstream drops the junk. Bounded — a sustained
8855    // flood past this depth degrades to "adopt one pass late", never a false state.
8856    const REKEY_PAGE: usize = 200;
8857    const REKEY_MAX_PAGES: usize = 6;
8858    let mut out = Vec::new();
8859    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
8860    let mut until: Option<u64> = None;
8861    let mut oldest: Option<u64> = None;
8862    for _ in 0..REKEY_MAX_PAGES {
8863        let query = Query {
8864            kinds: vec![stream::KIND_WRAP],
8865            authors: vec![group.pk_hex()],
8866            until,
8867            limit: Some(REKEY_PAGE),
8868            ..Default::default()
8869        };
8870        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
8871        // shared user-authed client's REQ for a plane's events is CLOSED, so an
8872        // offline rotation catch-up would return nothing and wedge at the old
8873        // epoch. `fetch_plane` rides a connection authed as the plane itself.
8874        let page = transport.fetch_plane(group.keys(), &query, relays).await?;
8875        // A page nobody fully answered must not read as "the plane ends here": the
8876        // short-page break below is a completeness verdict, and drawing it from a
8877        // degraded read is how a client concludes a rotation is absent when it is
8878        // merely unreachable.
8879        let page_complete = page.is_complete();
8880        let wraps = page.events;
8881        let mut fresh = 0usize;
8882        for w in &wraps {
8883            if !seen.insert(w.id) {
8884                continue;
8885            }
8886            fresh += 1;
8887            let at = w.created_at.as_secs();
8888            if oldest.is_none_or(|o| at < o) {
8889                oldest = Some(at);
8890            }
8891            if let Ok(opened) = stream::open_wrap(w, group) {
8892                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
8893                    out.push(chunk);
8894                }
8895            }
8896        }
8897        // Drained, or a same-second wall the pager can't step past (second-granular
8898        // until) — either way stop; the accumulated set is what advance_scope folds.
8899        if fresh == 0 || (wraps.len() < REKEY_PAGE && page_complete) {
8900            break;
8901        }
8902        match oldest {
8903            Some(o) if o > 0 => until = Some(o),
8904            _ => break,
8905        }
8906    }
8907    Ok(out)
8908}
8909
8910/// One addressing root's chunks, with the key held at that scope as `(epoch, key)`.
8911type RekeyBatch = (Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>);
8912
8913/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
8914/// batch pairs the chunks fetched under one root with the continuity to demand of
8915/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
8916/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
8917/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
8918/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
8919/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
8920/// seal authority is the boundary). Among qualifying rotations carrying my blob
8921/// the lexicographically lowest new key wins (convergent). All complete
8922/// candidates without my blob conclude Removed for a KEYED holder only when one
8923/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
8924/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
8925/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
8926#[allow(clippy::too_many_arguments)]
8927async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
8928    batches: &[RekeyBatch],
8929    scope: RekeyScope,
8930    community_id: &crate::community::CommunityId,
8931    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
8932    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
8933    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
8934    signer: &S,
8935    my_xonly: &[u8; 32],
8936    next_epoch: Epoch,
8937) -> Advance {
8938    let mut winners: Vec<rekey::BaseKeyDelivery> = Vec::new();
8939    // Parallel to `winners`: whether each delivery's rotation is a containment
8940    // (Root scope only). Selected by the same lowest-key index, so a same-epoch
8941    // fork adopts the winner's flag and never a loser's.
8942    let mut winner_severed: Vec<bool> = Vec::new();
8943    // Parallel to `winner_severed`: whose Refounding this delivery came from. The
8944    // snapshot authority for the adopted epoch (CORD-02 §5) is exactly this npub.
8945    let mut winner_rotator: Vec<PublicKey> = Vec::new();
8946    let mut saw_complete_candidate = false;
8947    // Did any qualifying rotation actually carry recipients? See the removal gate.
8948    let mut saw_nonempty_candidate = false;
8949    let mut saw_outranking_candidate = false;
8950    let mut my_blob_unopenable = false;
8951    let keyed = batches.iter().any(|(_, held)| held.is_some());
8952    for (chunks, held) in batches {
8953        let rotations = rekey::collect_rotations(chunks);
8954        for r in &rotations {
8955            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 {
8956                continue;
8957            }
8958            if let Some((held_epoch, held_key)) = held {
8959                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
8960                    continue;
8961                }
8962            }
8963            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
8964            // target. An authorized-but-inadmissible rotation (one that excludes
8965            // the owner or a peer/superior the rotator can't act on) is a takeover
8966            // attempt — skip it entirely, so it neither adopts nor concludes a
8967            // removal (it forks; the honest chain wins).
8968            if !admissible(r) {
8969                continue;
8970            }
8971            // Completeness gates the DESTRUCTIVE direction only. Concluding removal
8972            // means "no blob for me anywhere in this rotation", which is only
8973            // knowable with every chunk in hand — so `saw_complete_candidate`, and
8974            // everything downstream of it, still demands the whole set.
8975            //
8976            // ADOPTION does not need it. The blob binds its own (scope, epoch)
8977            // inside the ciphertext, so a key that opens is this rotation's key
8978            // whatever else is unreachable; and admissibility is monotonic in the
8979            // blob set — chunks we have not seen can only ADD recipients, so a
8980            // rotation that looks admissible on the blobs we hold cannot become a
8981            // takeover once the rest arrive. Requiring the full set to adopt made a
8982            // large community's rotation as fragile as its least reachable chunk,
8983            // and the walk is sequential, so that strand blocked every epoch above
8984            // it too. (A rotation that fails admissibility on partial data is still
8985            // skipped, never adopted — the unsafe direction stays closed.)
8986            if r.is_complete() {
8987                saw_complete_candidate = true;
8988                saw_nonempty_candidate |= !r.blobs.is_empty();
8989                saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
8990            }
8991            // EVERY blob at my locator, not just the first. A locator is a public
8992            // index that proves nothing, so anyone may publish a blob at mine;
8993            // taking only the first would let a junk one shadow the real key and
8994            // park a client that actually holds it. First that opens wins.
8995            let mut mine = rekey::find_my_blobs(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch).peekable();
8996            let had_candidate = mine.peek().is_some();
8997            let mut opened_one = false;
8998            for blob in mine {
8999                let opened = match scope {
9000                    // Base blobs are width-declared forms (CORD-06 §1).
9001                    RekeyScope::Root => rekey::open_base_blob(signer, &r.rotator, community_id, r.new_epoch, blob).await,
9002                    RekeyScope::Channel(_) => rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob)
9003                        .await
9004                        .map(|k| rekey::BaseKeyDelivery { new_root: k, control_pk: None, control_root: None }),
9005                };
9006                if let Ok(d) = opened {
9007                    winners.push(d);
9008                    winner_severed.push(scope.id32() == RekeyScope::Root.id32() && r.severed);
9009                    winner_rotator.push(r.rotator);
9010                    opened_one = true;
9011                    break;
9012                }
9013            }
9014            // A blob AT my locator that won't open is not an exclusion (CORD-06 §2:
9015            // removal = NO blob across all chunks). It must never conclude Removed
9016            // below — that is exactly how a pre-split client turned an unreadable
9017            // width into a false self-removal. Stay and keep recovering instead.
9018            if had_candidate && !opened_one {
9019                my_blob_unopenable = true;
9020            }
9021        }
9022    }
9023    if !winners.is_empty() {
9024        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
9025        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
9026        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
9027        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
9028        // the same epoch (separate rotations): every follower converges on the same
9029        // lowest new BASE key — the control pair rides the winner's blobs, never
9030        // compared (CORD-06 §3). A wrap served under two addressing roots can't
9031        // double-count: each rekey wrap opens under exactly one root's group key.
9032        let keys: Vec<[u8; 32]> = winners.iter().map(|d| d.new_root).collect();
9033        let idx = rekey::lowest_key_winner(&keys).expect("winners is non-empty");
9034        let w = &winners[idx];
9035        return Advance::Adopt {
9036            new_key: w.new_root,
9037            control_pk: w.control_pk,
9038            control_root: w.control_root,
9039            severed: winner_severed[idx],
9040            rotator: winner_rotator[idx],
9041        };
9042    }
9043    // A rotation that delivered NO blob to anyone removed nobody. Read literally it
9044    // is "complete, authorized, and contains no blob for me" — which is the removal
9045    // shape — but removal DELETES the community locally, so one empty rotation from
9046    // an authorized rotator would evict the entire membership at once, irreversibly.
9047    // An empty recipient set is a malformed or aborted rotation, never a verdict
9048    // about any individual. Nobody is removed by a rotation that removed everybody.
9049    if saw_complete_candidate && !saw_nonempty_candidate {
9050        return Advance::Stay;
9051    }
9052    if saw_complete_candidate && !my_blob_unopenable && (!keyed || saw_outranking_candidate) {
9053        Advance::Removed
9054    } else if my_blob_unopenable {
9055        Advance::StayWedged
9056    } else {
9057        Advance::Stay
9058    }
9059}
9060
9061// ── Pins (CORD-04 §7) ────────────────────────────────────────────────────────
9062
9063/// A channel's pin list, read from the locally folded head.
9064#[derive(Debug, serde::Serialize)]
9065pub struct ChannelPins {
9066    /// Entries that passed the full §7 verification, wire order (curator's).
9067    pub pins: Vec<super::pins::VerifiedPin>,
9068    /// The head is sealed under a key epoch this client does not hold: the
9069    /// pins exist but are unreadable. Render as unavailable, NEVER as empty —
9070    /// and a writer seeing this MUST NOT publish (it would drop every entry).
9071    pub sealed: bool,
9072    /// Folded head version (0 = no edition has ever folded).
9073    pub version: u64,
9074}
9075
9076/// The channel's stream conversation key at `epoch`, if this client holds the
9077/// deriving secret: a private channel's held per-epoch key, a public channel's
9078/// held base root at that epoch.
9079fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
9080    let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
9081    // A private plane is never derived from the root value (that would address
9082    // the public plane) — mirrors fetch_channel_history's invariant.
9083    if ch.private && ikm == community.community_root {
9084        return None;
9085    }
9086    let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
9087    group.conv_key().as_bytes().try_into().ok()
9088}
9089
9090/// Read a channel's pins from the locally folded head: unseal (private form),
9091/// verify every entry, keep wire order. Local-only — the control follow is what
9092/// moves the head.
9093pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
9094    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
9095    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9096    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9097    let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
9098        return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
9099    };
9100    let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
9101    let pins = read
9102        .entries
9103        .iter()
9104        .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
9105        .collect();
9106    Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
9107}
9108
9109/// Publish `entries` as the channel's next Pin List edition, in the form the
9110/// channel's folded type mandates, and echo it locally so a follow-up edit
9111/// builds on this write rather than the pre-write fold.
9112async fn publish_pin_list<T: Transport + ?Sized>(
9113    transport: &T,
9114    community: &CommunityV2,
9115    ch: &ChannelV2,
9116    entries: &[super::pins::PinEntry],
9117) -> Result<(), String> {
9118    crate::db::scoped(async move {
9119        let content = if ch.private {
9120            let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
9121            let group = channel_group_key(&key, &ch.id, ch.epoch);
9122            super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
9123        } else {
9124            super::pins::serialize_public_pin_list(entries)?
9125        };
9126        let eid = super::derive::pins_locator(community.id(), &ch.id);
9127        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9128        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
9129        let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
9130        // The version this publish will chain to — mirrors publish_control_edition.
9131        let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
9132            Some((v, _)) => v + 1,
9133            None => 1,
9134        };
9135        crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
9136        publish_control_edition(transport, community, vsk::PINS, &eid, &content).await?;
9137        let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
9138        crate::emit_event(
9139            "community_pins_updated",
9140            &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
9141        );
9142        if let Ok(me) = me_pk() {
9143            use nostr_sdk::prelude::ToBech32;
9144            let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
9145            note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
9146        }
9147        Ok(())
9148    })
9149    .await
9150}
9151
9152/// One centered system row per adopted Pin List edition — "X modified the
9153/// Pins". The id is deterministic on (channel, version), so the publisher's
9154/// echo and every fold that adopts the same edition collapse into one row,
9155/// and a catch-up fold stamps the edition's own time so history sorts true.
9156async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
9157    let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
9158    let inserted = crate::db::events::save_system_event_at(
9159        &event_id,
9160        channel_hex,
9161        crate::stored_event::SystemEventType::PinsModified,
9162        actor_npub,
9163        None,
9164        at_secs,
9165        None,
9166        None,
9167    )
9168    .await
9169    .unwrap_or(false);
9170    if inserted {
9171        crate::emit_event(
9172            "system_event",
9173            &serde_json::json!({
9174                "conversation_id": channel_hex,
9175                "event_id": event_id,
9176                "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
9177                "member_pubkey": actor_npub,
9178            }),
9179        );
9180    }
9181}
9182
9183/// The current entries this writer may build on. Replace-entire cuts sharply
9184/// (§7): an empty view has two innocent causes indistinguishable from an empty
9185/// list, so a writer MUST refuse to build from a list it could not read.
9186fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
9187    let current = read_channel_pins(community, channel_id)?;
9188    if current.sealed {
9189        return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
9190    }
9191    Ok(current.pins.into_iter().map(|p| p.entry).collect())
9192}
9193
9194/// Pin a message: recover its wrap, rebuild its proof, append, republish.
9195///
9196/// The seal is re-fetched from the community relays by the stored wrapper id —
9197/// the DB retains rumors, not seals, and a proof needs the seal verbatim.
9198pub async fn pin_message<T: Transport + ?Sized>(
9199    transport: &T,
9200    community: &CommunityV2,
9201    channel_id: &ChannelId,
9202    rumor_id_hex: &str,
9203) -> Result<(), String> {
9204    crate::db::scoped(async move {
9205        let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
9206        let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9207
9208        let mut entries = writable_pin_entries(community, channel_id)?;
9209        if entries.len() >= super::pins::PIN_MAX_ENTRIES {
9210            return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
9211        }
9212        // Idempotent: re-pinning an already-pinned message is a no-op, not an error.
9213        if entries
9214            .iter()
9215            .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
9216            .any(|v| v.rumor_id == rumor_id_hex)
9217        {
9218            return Ok(());
9219        }
9220
9221        let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
9222            .ok_or("message not found in this device's history")?;
9223        let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
9224
9225        // Recover the wrap verbatim — Full evidence: a pin is a permanent artifact,
9226        // so don't build it from the first relay to answer.
9227        let wraps = transport
9228            .fetch(
9229                &Query {
9230                    ids: vec![wrap_id.clone()],
9231                    kinds: vec![super::stream::KIND_WRAP],
9232                    limit: Some(1),
9233                    evidence: crate::community::transport::Evidence::Full,
9234                    ..Default::default()
9235                },
9236                &community.relays,
9237            )
9238            .await?;
9239        let wrap = wraps
9240            .iter()
9241            .find(|w| w.id.to_hex() == wrap_id)
9242            .ok_or("the message's wrap is no longer served by this community's relays")?;
9243
9244        // The stored row does not retain the rumor's epoch binding, so re-derive it
9245        // the way history reads do: try the channel's every held plane coordinate,
9246        // current epoch first, until the wrap opens AND carries this rumor. The
9247        // open itself verifies the channel + epoch binding, so a false coordinate
9248        // fails closed rather than mis-attributing.
9249        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9250        let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
9251        if ch.private {
9252            if let Some(k) = ch.key {
9253                coords.push((ch.epoch.0, k));
9254            }
9255            let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
9256            coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
9257        } else {
9258            coords.push((community.root_epoch.0, community.community_root));
9259            let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
9260            coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
9261        }
9262        coords.dedup();
9263
9264        let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
9265        for (epoch, ikm) in coords {
9266            let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
9267            if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
9268                super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
9269            {
9270                if opened.rumor_id.to_hex() == rumor_id_hex {
9271                    let conv: [u8; 32] = group
9272                        .conv_key()
9273                        .as_bytes()
9274                        .try_into()
9275                        .map_err(|_| "conversation key size".to_string())?;
9276                    found = Some((opened, conv));
9277                    break;
9278                }
9279            }
9280        }
9281        let Some((opened, conv_key)) = found else {
9282            return Err("that message is from a key epoch this device no longer holds".to_string());
9283        };
9284
9285        let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
9286            super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
9287            super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
9288            super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
9289        })?;
9290        entries.push(entry);
9291
9292        publish_pin_list(transport, community, &ch, &entries).await
9293    })
9294    .await
9295}
9296
9297/// The deriving secret (ikm) for a channel plane at `epoch` — the same lookup
9298/// `channel_conv_key_at` performs, surfaced for the open path.
9299fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
9300    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9301    if ch.private {
9302        if ch.epoch.0 == epoch {
9303            return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
9304        }
9305        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
9306        crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
9307            .unwrap_or_default()
9308            .into_iter()
9309            .find(|(ep, _)| ep.0 == epoch)
9310            .map(|(_, k)| k)
9311            .ok_or("that message is from a key epoch this device no longer holds".to_string())
9312    } else if community.root_epoch.0 == epoch {
9313        Ok(community.community_root)
9314    } else {
9315        crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
9316            .unwrap_or_default()
9317            .into_iter()
9318            .find(|(ep, _)| ep.0 == epoch)
9319            .map(|(_, k)| k)
9320            .ok_or("that message is from a root epoch this device no longer holds".to_string())
9321    }
9322}
9323
9324/// Unpin a message: the next edition without the entry (§7 — no deletion event).
9325pub async fn unpin_message<T: Transport + ?Sized>(
9326    transport: &T,
9327    community: &CommunityV2,
9328    channel_id: &ChannelId,
9329    rumor_id_hex: &str,
9330) -> Result<(), String> {
9331    let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
9332    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9333    let entries = writable_pin_entries(community, channel_id)?;
9334    let kept: Vec<super::pins::PinEntry> = entries
9335        .into_iter()
9336        .filter(|e| {
9337            super::pins::verify_pin_entry(e, &ch_hex)
9338                .map(|v| v.rumor_id != rumor_id_hex)
9339                // An entry we can't verify is kept: unpin removes exactly the
9340                // named message, never collateral.
9341                .unwrap_or(true)
9342        })
9343        .collect();
9344    publish_pin_list(transport, community, &ch, &kept).await
9345}
9346
9347/// §7 curator duties: converge the Pin List when a pinned message is deleted
9348/// or edited. Spawned fire-and-forget from ingest — a non-curator, a sealed
9349/// list, or an unpinned target all no-op silently; the duty is voluntary.
9350pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
9351    let channel_hex = channel_hex.to_string();
9352    let target = target_rumor_hex.to_string();
9353    crate::db::spawn_bound(async move {
9354        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
9355        let _ = run_pin_duty(&transport, &channel_hex, &target, edit).await;
9356    });
9357}
9358
9359/// The duty body, transport-injected so tests can drive it end to end.
9360///
9361/// The affected author acts at once; every other PIN_MESSAGES holder waits a
9362/// deterministic 5-25s stagger (hashed from (me, target) — no thundering herd
9363/// of racing editions) and re-reads before publishing, so a duty another
9364/// curator already performed dissolves into a no-op.
9365async fn run_pin_duty<T: Transport + ?Sized>(
9366    transport: &T,
9367    channel_hex: &str,
9368    target: &str,
9369    edit: Option<super::stream::OpenedStream>,
9370) -> Result<(), String> {
9371    use crate::community::roles::Permissions;
9372    let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
9373        return Ok(());
9374    };
9375    let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
9376    let Some(community) = crate::db::community::load_community_v2(&cid)? else {
9377        return Ok(());
9378    };
9379    let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
9380
9381    // Cheap pre-checks before any waiting: pinned target, readable list, held bit.
9382    let read = read_channel_pins(&community, &channel_id)?;
9383    if read.sealed {
9384        return Ok(());
9385    }
9386    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9387        return Ok(());
9388    };
9389    if let Some(ed) = &edit {
9390        // Monotonic: a bundle at or past this revision needs no refresh.
9391        if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9392            return Ok(());
9393        }
9394    }
9395    let me = me_pk()?;
9396    let me_hex = me.to_hex();
9397    let owner_hex = community.owner()?.to_hex();
9398    let roster = crate::db::community::get_community_roles(&cid_hex)?;
9399    if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
9400        return Ok(());
9401    }
9402
9403    if hit.author != me_hex {
9404        let mut h: u32 = 0;
9405        for b in me_hex.bytes().chain(target.bytes()) {
9406            h = h.wrapping_mul(31).wrapping_add(u32::from(b));
9407        }
9408        crate::rt::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
9409    }
9410
9411    // Re-read after the stagger: another curator's edition may have landed.
9412    let read = read_channel_pins(&community, &channel_id)?;
9413    if read.sealed {
9414        return Ok(());
9415    }
9416    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
9417        return Ok(());
9418    };
9419    let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
9420
9421    let entries: Vec<super::pins::PinEntry> = match &edit {
9422        // Deletion: the next edition simply omits the entry (§7 — replace-entire).
9423        None => read
9424            .pins
9425            .iter()
9426            .filter(|p| p.rumor_id != target)
9427            .map(|p| p.entry.clone())
9428            .collect(),
9429        // Edit: the same entry, its bundle refreshed to the newest revision.
9430        Some(ed) => {
9431            if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
9432                return Ok(());
9433            }
9434            let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
9435            // The edit sealed under the channel's current plane in the common
9436            // (realtime) case; older epochs are tried like every other read.
9437            let mut bundle = None;
9438            let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
9439            let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
9440            epochs.extend(
9441                crate::db::community::held_epoch_keys(&cid_hex, &scope)
9442                    .unwrap_or_default()
9443                    .into_iter()
9444                    .map(|(ep, _)| ep.0),
9445            );
9446            epochs.dedup();
9447            for epoch in epochs {
9448                let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
9449                if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
9450                    bundle = Some(b);
9451                    break;
9452                }
9453            }
9454            let Some(bundle) = bundle else { return Ok(()) };
9455            read.pins
9456                .iter()
9457                .map(|p| {
9458                    let mut entry = p.entry.clone();
9459                    if p.rumor_id == target {
9460                        entry.edit = Some(bundle.clone());
9461                    }
9462                    entry
9463                })
9464                .collect()
9465        }
9466    };
9467
9468    crate::log_info!(
9469        "[pins] duty {} for target {} in channel {}",
9470        if edit.is_some() { "edit-refresh" } else { "omission" },
9471        &target[..12],
9472        &channel_hex[..12]
9473    );
9474    publish_pin_list(transport, &community, &ch, &entries).await
9475}
9476
9477/// Silent owner-side widening: an Admin role published before PIN_MESSAGES
9478/// existed gains the bit, so delegated admins can curate pins in communities
9479/// founded before this build. One edition, idempotent, converges across owner
9480/// devices (both publish the same widened mask as editions of one entity).
9481pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
9482    transport: &T,
9483    community: &CommunityV2,
9484) -> Result<bool, String> {
9485    use crate::community::roles::{Permissions, RoleScope};
9486    let my_pk = me_pk()?;
9487    if community.owner()? != my_pk {
9488        return Ok(false);
9489    }
9490    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9491    let roles = crate::db::community::get_community_roles(&cid_hex)?;
9492    let Some(role) = roles.roles.iter().find(|r| {
9493        matches!(r.scope, RoleScope::Server)
9494            && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
9495            && !r.permissions.contains(Permissions::PIN_MESSAGES)
9496    }) else {
9497        return Ok(false);
9498    };
9499    let mut widened = role.clone();
9500    widened.permissions.0 |= Permissions::PIN_MESSAGES;
9501    set_role(transport, community, &widened).await?;
9502    Ok(true)
9503}
9504
9505#[cfg(test)]
9506mod tests {
9507    // The legacy (pre-split) Control derivation, still reachable for the
9508    // migration-compat paths under test.
9509    use super::super::derive::control_group_key;
9510    use crate::ClientRelayExt;
9511    use nostr_sdk::prelude::FinalizeEvent;
9512    use super::super::super::transport::memory::MemoryRelay;
9513    use super::*;
9514    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
9515
9516    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
9517    fn account_name(n: u32) -> String {
9518        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
9519        let mut acct = String::from("npub1");
9520        let mut v = n as usize;
9521        for _ in 0..58 {
9522            acct.push(B[v % 32] as char);
9523            v = v / 32 + 7;
9524        }
9525        acct
9526    }
9527
9528    /// One test participant: its identity keys and its isolated account DB dir.
9529    struct Actor {
9530        keys: Keys,
9531        account: String,
9532    }
9533
9534    /// Two participants sharing one relay but isolated per-account DBs — the
9535    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
9536    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
9537    /// the per-account id caches, so account A's community is invisible to B
9538    /// until B legitimately joins.
9539    struct TestBed {
9540        _tmp: tempfile::TempDir,
9541        _guard: std::sync::MutexGuard<'static, ()>,
9542        relay: MemoryRelay,
9543        relays: Vec<String>,
9544    }
9545
9546    impl TestBed {
9547        fn new() -> (TestBed, Actor, Actor) {
9548            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
9549            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9550            crate::db::close_database();
9551            crate::db::clear_id_caches();
9552            let tmp = tempfile::tempdir().unwrap();
9553            crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9554
9555            let mk = || {
9556                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9557                let account = account_name(n);
9558                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
9559                crate::db::set_current_account(account.clone()).unwrap();
9560                crate::db::init_database(&account).unwrap();
9561                Actor { keys: Keys::generate(), account }
9562            };
9563            let owner = mk();
9564            let member = mk();
9565            let _ = crate::state::take_nostr_client();
9566            let bed = TestBed {
9567                _tmp: tmp,
9568                _guard: guard,
9569                relay: MemoryRelay::new(),
9570                relays: vec!["wss://r".to_string()],
9571            };
9572            (bed, owner, member)
9573        }
9574
9575        /// Become `actor`: swap the account DB + identity, as a real session swap.
9576        /// Bumps the session generation like production `swap_session` does — so any task a
9577        /// prior actor spawned (e.g. the migration finalize) dies at its std::sync::Arc<crate::db::Session> check
9578        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
9579        fn swap_to(&self, actor: &Actor) {
9580            crate::db::close_database();
9581            crate::db::set_current_account(actor.account.clone()).unwrap();
9582            crate::db::init_database(&actor.account).unwrap();
9583            crate::db::clear_id_caches();
9584            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
9585            crate::state::set_my_public_key(actor.keys.public_key());
9586        }
9587    }
9588
9589    /// Legacy single-actor helper (the create/send tests below).
9590    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
9591        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9592        crate::db::close_database();
9593        crate::db::clear_id_caches();
9594        let tmp = tempfile::tempdir().unwrap();
9595        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
9596        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9597        let acct = account_name(n);
9598        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
9599        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
9600        crate::db::set_current_account(acct.clone()).unwrap();
9601        crate::db::init_database(&acct).unwrap();
9602        let _ = crate::state::take_nostr_client();
9603        let owner = Keys::generate();
9604        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
9605        crate::state::set_my_public_key(owner.public_key());
9606        (tmp, guard, owner)
9607    }
9608
9609    /// A transport that simulates a session swap landing DURING a fetch await —
9610    /// so a join straddling the fetch sees an invalid session and aborts.
9611    /// Switches to a real second account on the first fetch, then stays out of
9612    /// the way. It BORROWS the relay that built the community — a fresh one
9613    /// would fail verification for want of a control plane, which is not the
9614    /// thing being tested.
9615    struct SwapMidFetch<'a> {
9616        inner: &'a MemoryRelay,
9617        to: String,
9618        armed: std::sync::atomic::AtomicBool,
9619    }
9620
9621    impl<'a> SwapMidFetch<'a> {
9622        fn arm(inner: &'a MemoryRelay) -> Self {
9623            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(88_000);
9624            let to = account_name(N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
9625            std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
9626            Self { inner, to, armed: std::sync::atomic::AtomicBool::new(true) }
9627        }
9628    }
9629
9630    #[async_trait::async_trait]
9631    impl Transport for SwapMidFetch<'_> {
9632        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9633        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9634            self.inner.publish(e, r).await
9635        }
9636        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9637            self.inner.publish_durable(e, r).await
9638        }
9639        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9640            let out = self.inner.fetch(q, r).await;
9641            if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
9642                crate::db::set_current_account(self.to.clone()).unwrap();
9643                crate::db::init_database(&self.to).unwrap();
9644            }
9645            out
9646        }
9647    }
9648
9649    /// Bumps the session generation on the first `publish_durable` — the rekey
9650    /// crate a private-channel create ships before it writes anything locally.
9651    struct SwapMidPublish {
9652        inner: MemoryRelay,
9653    }
9654    #[async_trait::async_trait]
9655    impl Transport for SwapMidPublish {
9656        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9657        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
9658            self.inner.publish(e, r).await
9659        }
9660        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
9661            let out = self.inner.publish_durable(e, r).await;
9662            crate::db::close_database();
9663            out
9664        }
9665        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
9666            self.inner.fetch(q, r).await
9667        }
9668    }
9669
9670    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
9671    /// the production `LiveTransport` union (first-responding relay's batch, no
9672    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
9673    /// the only harness that can exercise the revocation-race ordering.
9674    struct FixedFetch {
9675        events: Vec<Event>,
9676    }
9677    #[async_trait::async_trait]
9678    impl Transport for FixedFetch {
9679        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
9680        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9681            Ok(())
9682        }
9683        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9684            Ok(())
9685        }
9686        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9687            Ok(self.events.clone())
9688        }
9689    }
9690
9691    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
9692    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
9693    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
9694        let q = Query {
9695            kinds: vec![stream::KIND_WRAP],
9696            p_tags: vec![me.to_hex()],
9697            k_tags: vec!["3313".to_string()],
9698            ..Default::default()
9699        };
9700        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
9701    }
9702
9703    #[tokio::test]
9704    async fn create_persists_and_reloads_a_v2_community() {
9705        let (_tmp, _guard, owner) = init_test_db();
9706        let relay = MemoryRelay::new();
9707        let relays = vec!["wss://r".to_string()];
9708
9709        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
9710        assert!(created.identity.verify());
9711        assert_eq!(created.owner().unwrap(), owner.public_key());
9712        assert_eq!(created.channels.len(), 1);
9713
9714        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
9715        assert_eq!(
9716            crate::db::community::community_protocol(created.id()).unwrap(),
9717            Some(crate::community::ConcordProtocol::V2)
9718        );
9719        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
9720        assert_eq!(loaded.name, "Vectorville");
9721        assert_eq!(loaded.community_root, created.community_root);
9722        assert_eq!(loaded.identity, created.identity);
9723        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
9724        assert!(!loaded.channels[0].private);
9725
9726        // The genesis control editions + the owner Join landed on the relay.
9727        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
9728    }
9729
9730    #[tokio::test]
9731    async fn owner_sends_and_reads_back_a_message() {
9732        let (_tmp, _guard, _owner) = init_test_db();
9733        let relay = MemoryRelay::new();
9734        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
9735        let general = community.channels[0].id;
9736
9737        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
9738        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
9739        assert_ne!(id1, id2);
9740
9741        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9742        let texts: Vec<String> = page
9743            .iter()
9744            .filter_map(|f| match &f.event {
9745                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9746                _ => None,
9747            })
9748            .collect();
9749        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
9750    }
9751
9752    #[test]
9753    fn send_stamps_never_repeat_within_a_process() {
9754        // Ordering is by the `ms` tag, and an optimized build seals several sends
9755        // per millisecond. Identical stamps fell through to the reader's
9756        // content-derived tiebreak, which knows nothing about which was typed
9757        // first, so a fast sender could watch its own messages come back
9758        // shuffled — the flake in `owner_sends_and_reads_back_a_message` once the
9759        // suite began compiling optimized. Distinctness must not depend on how
9760        // slow the build happens to be.
9761        let stamps: Vec<u64> = (0..5_000).map(|_| next_send_ms()).collect();
9762        for pair in stamps.windows(2) {
9763            assert!(pair[1] > pair[0], "stamps must strictly increase: {} then {}", pair[0], pair[1]);
9764        }
9765    }
9766
9767    #[tokio::test]
9768    async fn same_millisecond_messages_order_by_rumor_id_not_relay_order() {
9769        // Cross-client half: when stamps DO collide (two senders, clock skew),
9770        // every reader must still agree. A bare `ms` sort left ties in whatever
9771        // order the relay served them across pages, so two readers could show the
9772        // same pair in opposite orders. The rumor id is content-derived, so
9773        // pinning ties to it is something every client computes identically.
9774        let (_tmp, _guard, _owner) = init_test_db();
9775        let relay = MemoryRelay::new();
9776        let community = create_community(&relay, "Ties", vec!["wss://r".into()], None).await.unwrap();
9777        let general = community.channels[0].id;
9778
9779        let at = now_ms();
9780        for i in 0..6 {
9781            send_chat_message_at(&relay, &community, &general, &format!("tied {i}"), None, &[], vec![], at)
9782                .await
9783                .unwrap();
9784        }
9785        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9786        let msgs: Vec<(Vec<u8>, String)> = page
9787            .iter()
9788            .filter_map(|f| match &f.event {
9789                ChatEvent::Message { .. } => {
9790                    let o = f.event.opened();
9791                    Some((o.rumor_id.as_bytes().to_vec(), o.rumor.content.clone()))
9792                }
9793                _ => None,
9794            })
9795            .collect();
9796        assert_eq!(msgs.len(), 6, "all six tied messages read back");
9797        let got: Vec<String> = msgs.iter().map(|(_, c)| c.clone()).collect();
9798        let mut by_id = msgs.clone();
9799        by_id.sort_by(|a, b| a.0.cmp(&b.0));
9800        let expected: Vec<String> = by_id.into_iter().map(|(_, c)| c).collect();
9801        assert_eq!(got, expected, "ties resolve by rumor id, never by the relay's serving order");
9802    }
9803
9804    #[tokio::test]
9805    async fn a_rapid_burst_carries_strictly_increasing_stamps() {
9806        // The call-site half: sends must draw from the monotonic stamp, not the
9807        // raw clock. An optimized build seals several per millisecond, and equal
9808        // stamps hand a sender's own ordering to the content tiebreak, which
9809        // knows nothing about typing order.
9810        let (_tmp, _guard, _owner) = init_test_db();
9811        let relay = MemoryRelay::new();
9812        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9813        let general = community.channels[0].id;
9814        for i in 0..8 {
9815            send_message(&relay, &community, &general, &format!("msg {i}")).await.unwrap();
9816        }
9817        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
9818        let stamps: Vec<u64> = page
9819            .iter()
9820            .filter_map(|f| match &f.event {
9821                ChatEvent::Message { .. } => Some(f.event.opened().at_ms),
9822                _ => None,
9823            })
9824            .collect();
9825        assert_eq!(stamps.len(), 8);
9826        for pair in stamps.windows(2) {
9827            assert!(pair[1] > pair[0], "a sender's own stamps must strictly increase, got {pair:?}");
9828        }
9829    }
9830
9831    #[tokio::test]
9832    async fn a_second_member_reads_the_public_channel_from_the_root() {
9833        // A member who holds the community_root (via an invite bundle, modeled
9834        // here by cloning the community) reads the owner's public-channel message
9835        // — public channels need no key delivery, they derive from the root.
9836        let (_tmp, _guard, _owner) = init_test_db();
9837        let relay = MemoryRelay::new();
9838        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
9839        let general = community.channels[0].id;
9840        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
9841
9842        // The "member" reconstructs the same read coordinates from the root.
9843        let member_view = community.clone();
9844        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
9845        assert_eq!(page.len(), 1);
9846        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
9847        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
9848    }
9849
9850    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
9851
9852    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
9853        fetch_channel(relay, community, channel, 100)
9854            .await
9855            .unwrap()
9856            .iter()
9857            .filter_map(|f| match &f.event {
9858                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9859                _ => None,
9860            })
9861            .collect()
9862    }
9863
9864    /// History as the BACK-PAGING cursor reads it: every held epoch. Opening a
9865    /// channel deliberately reads only the live planes, so pre-rotation history is
9866    /// reached by paging, which is what a user scrolling up actually does.
9867    async fn all_texts_in<T: crate::community::transport::Transport + ?Sized>(
9868        relay: &T,
9869        community: &CommunityV2,
9870        channel: &ChannelId,
9871    ) -> Vec<String> {
9872        let from_now = now_ms() / 1000 + 3600;
9873        fetch_channel_history(
9874            relay,
9875            community,
9876            channel,
9877            100,
9878            8,
9879            None,
9880            Some(from_now),
9881            crate::community::transport::Evidence::Quorum,
9882            |_| true,
9883        )
9884        .await
9885        .unwrap()
9886        .iter()
9887        .filter_map(|f| match &f.event {
9888            ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9889            _ => None,
9890        })
9891        .collect()
9892    }
9893
9894    #[tokio::test]
9895    async fn direct_invite_full_loop_owner_and_member_converse() {
9896        let (bed, owner, member) = TestBed::new();
9897
9898        // Owner creates a community, posts, and Direct-Invites the member's npub.
9899        bed.swap_to(&owner);
9900        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
9901        let general = community.channels[0].id;
9902        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
9903        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9904
9905        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
9906        bed.swap_to(&member);
9907        assert!(
9908            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9909            "the member does not hold the community before joining"
9910        );
9911        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9912        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9913        assert_eq!(joined.id().0, community.id().0, "joined the same community");
9914        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
9915        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
9916
9917        // The member reads the owner's public-channel history and replies.
9918        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
9919        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
9920
9921        // The owner reads the member's reply.
9922        bed.swap_to(&owner);
9923        assert_eq!(
9924            texts_in(&bed.relay, &community, &general).await,
9925            vec!["owner: welcome!", "member: thanks for the invite"],
9926            "both actors' messages interleave in ms order on the shared channel"
9927        );
9928
9929        // The Guestbook memberlist now folds both participants.
9930        let members = memberlist(&bed.relay, &community).await.unwrap();
9931        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
9932        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
9933        assert_eq!(members.len(), 2);
9934    }
9935
9936    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
9937    /// refuses to join — no Guestbook Join publish, no local write — through the shared
9938    /// accept path every door (direct invite, parked, public link, migration) funnels into.
9939    #[tokio::test]
9940    async fn a_banned_member_is_refused_at_join_time() {
9941        let (bed, owner, member) = TestBed::new();
9942
9943        bed.swap_to(&owner);
9944        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
9945        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9946        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
9947
9948        bed.swap_to(&member);
9949        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9950        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9951        assert!(err.contains("banned"), "refusal names the reason: {err}");
9952        assert!(
9953            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9954            "a refused join persists nothing"
9955        );
9956
9957        // The gate is the LAST word only for banned members: an unbanned bystander with
9958        // the same invite path still joins (the gate doesn't over-refuse).
9959        bed.swap_to(&owner);
9960        set_banlist(&bed.relay, &community, &[]).await.unwrap();
9961        bed.swap_to(&member);
9962        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9963        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
9964    }
9965
9966    /// End-to-end member migration: a member holding a v1 community folds the owner's
9967    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
9968    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
9969    #[tokio::test]
9970    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
9971        use crate::community::migration;
9972        let (bed, owner, member) = TestBed::new();
9973
9974        // Owner builds the v2 twin (real, verifiable on the shared relay).
9975        bed.swap_to(&owner);
9976        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
9977        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
9978        let jm = join_material(&v2);
9979
9980        // The member holds a v1 community owned by the SAME owner identity (the migration
9981        // premise) — construct + save it, and hold its server root.
9982        bed.swap_to(&member);
9983        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
9984        let v1_cid = v1.id.to_hex();
9985        v1.owner_attestation = Some({
9986            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
9987                .finalize(&owner.keys).unwrap().as_json()
9988        });
9989        crate::db::community::save_community(&v1).unwrap();
9990        let v1_channel = v1.channels[0].id.to_hex();
9991
9992        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
9993        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
9994        let signpost = migration::MigrationSignpost {
9995            v2_community_id: v2_hex.clone(),
9996            owner_xonly: owner.keys.public_key().to_hex(),
9997            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
9998            relays: bed.relays.clone(),
9999            name: "Guild".into(),
10000            primary_channel: v1_channel.clone(),
10001            root_epoch: 0,
10002        };
10003        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
10004        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
10005
10006        // Drive the migration: opens m, joins v2 (ban-gated), flips.
10007        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
10008        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
10009
10010        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
10011        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10012        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
10013        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
10014        let _ = v1_channel;
10015
10016        // Idempotent: a second drive is a no-op (already flipped).
10017        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
10018    }
10019
10020    /// The member half of a migration, for a member who already holds the twin. Every
10021    /// piece of the flip test bed except the pointer: owner runs the wizard (stitched
10022    /// twin), member holds the same v1 plus the twin via an ordinary save, which the
10023    /// hijack guard leaves channel-less because the stitched channel id is still v1-owned.
10024    async fn held_twin_bed(
10025        relay: &MemoryRelay,
10026        bed: &TestBed,
10027        owner: &Actor,
10028        member: &Actor,
10029    ) -> (crate::community::Community, String, String, String, CommunityV2) {
10030        use crate::community::migration;
10031        bed.swap_to(owner);
10032        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10033        let v1_cid = v1.id.to_hex();
10034        let v1_channel = v1.channels[0].id.to_hex();
10035        v1.owner_attestation = Some({
10036            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10037                .finalize(&owner.keys).unwrap().as_json()
10038        });
10039        crate::db::community::save_community(&v1).unwrap();
10040        let v2_hex = migration::migrate_community_to_v2(relay, &v1, migration::MIGRATION_UNLOCK_AT + 1).await.unwrap();
10041        let v2_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex));
10042        let twin = crate::db::community::load_community_v2(&v2_id).unwrap().expect("owner holds the twin");
10043
10044        bed.swap_to(member);
10045        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10046        m_v1.id = v1.id;
10047        m_v1.server_root_key = v1.server_root_key.clone();
10048        m_v1.channels[0].id = v1.channels[0].id;
10049        m_v1.owner_attestation = v1.owner_attestation.clone();
10050        crate::db::community::save_community(&m_v1).unwrap();
10051        crate::db::community::save_community_v2(&twin).unwrap();
10052        assert_eq!(
10053            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10054            Some(v1_cid.as_str()),
10055            "precondition: the stitched channel is still v1-owned (channel-less twin)"
10056        );
10057        (m_v1, v1_cid, v1_channel, v2_hex, twin)
10058    }
10059
10060    fn signpost_for(twin: &CommunityV2, v2_hex: &str, owner_xonly: String, salt: [u8; 32], primary: &str, relays: Vec<String>) -> crate::community::migration::MigrationSignpost {
10061        let _ = twin;
10062        crate::community::migration::MigrationSignpost {
10063            v2_community_id: v2_hex.to_string(),
10064            owner_xonly,
10065            owner_salt: crate::simd::hex::bytes_to_hex_32(&salt),
10066            relays,
10067            name: "Guild".into(),
10068            primary_channel: primary.to_string(),
10069            root_epoch: 0,
10070        }
10071    }
10072
10073    /// `m` only carries join material. A member who already holds the twin has nothing to
10074    /// join, so join material that will never open (sealed under a root this member does
10075    /// not hold) must not stop the flip. The flip is DB-only: nothing is published and no
10076    /// key changes; the stitched channel simply moves to the twin and becomes visible.
10077    #[tokio::test]
10078    async fn a_held_twin_flips_without_opening_m() {
10079        use crate::community::migration;
10080        let (bed, owner, member) = TestBed::new();
10081        let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10082        let v2_id = twin.identity.community_id;
10083
10084        // Sealed under a root nobody holds: a real read-cut / lost-DB shape.
10085        let m = migration::seal_m(&[7u8; 32], &serde_json::to_vec(&join_material(&twin)).unwrap()).unwrap();
10086        let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), twin.identity.owner_salt, &v1_channel, bed.relays.clone());
10087        crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, Some(m)).unwrap()).unwrap();
10088
10089        let published_before = bed.relay.stored_count();
10090        let roots_before = crate::db::community::held_epoch_keys(&v2_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
10091
10092        let flipped = migration::drive_migration(&bed.relay, &m_v1).await.unwrap();
10093        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "a held twin flips even though m cannot open");
10094
10095        assert_eq!(
10096            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10097            Some(v2_hex.as_str()),
10098            "the stitched channel moved to the twin"
10099        );
10100        assert!(
10101            crate::db::community::load_community_v2(&v2_id).unwrap().unwrap().channels.iter().any(|c| c.id.0 == m_v1.channels[0].id.0),
10102            "the twin now lists the channel (what a member actually sees)"
10103        );
10104        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10105        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "fence layer 0 set");
10106        assert_eq!(bed.relay.stored_count(), published_before, "a held flip publishes nothing");
10107        assert_eq!(
10108            crate::db::community::held_epoch_keys(&v2_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(),
10109            roots_before,
10110            "a held flip touches no keys"
10111        );
10112        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "idempotent");
10113    }
10114
10115    /// The held-twin shortcut sits BEHIND owner continuity: a pointer whose owner is not
10116    /// this v1's proven owner must not flip, even when the account holds a twin that the
10117    /// signpost describes perfectly. Built so that ONLY the continuity gate refuses: the
10118    /// member owns the named twin, so self-cert and the held-owner check both pass, and
10119    /// the reparent would move the v1 channel if the gate were skipped.
10120    #[tokio::test]
10121    async fn a_held_twin_still_refuses_an_owner_discontinuity() {
10122        use crate::community::migration;
10123        let (bed, owner, member) = TestBed::new();
10124        let (m_v1, v1_cid, v1_channel, _v2_hex, _twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10125
10126        // The member's OWN v2 community: a twin the signpost can describe consistently.
10127        let mine = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
10128        let mine_hex = crate::simd::hex::bytes_to_hex_32(&mine.identity.community_id.0);
10129        let signpost = signpost_for(&mine, &mine_hex, member.keys.public_key().to_hex(), mine.identity.owner_salt, &v1_channel, bed.relays.clone());
10130        crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10131
10132        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "refused");
10133        assert_eq!(
10134            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10135            Some(v1_cid.as_str()),
10136            "nothing moved"
10137        );
10138        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence");
10139    }
10140
10141    /// The held-twin shortcut sits BEHIND the self-cert check: a signpost naming the real,
10142    /// held twin under the real owner but a wrong salt must not flip. Only self-cert
10143    /// refuses here (continuity and the held-owner check both pass).
10144    #[tokio::test]
10145    async fn a_held_twin_still_refuses_a_forged_self_cert() {
10146        use crate::community::migration;
10147        let (bed, owner, member) = TestBed::new();
10148        let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&bed.relay, &bed, &owner, &member).await;
10149
10150        let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), [9u8; 32], &v1_channel, bed.relays.clone());
10151        crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10152
10153        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None, "refused");
10154        assert_eq!(
10155            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10156            Some(v1_cid.as_str()),
10157            "nothing moved"
10158        );
10159        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence");
10160    }
10161
10162    /// The held-twin flip runs under the twin's follow lock, like every other flip: a
10163    /// follow pass mid-flight holds a channel-less view whose whole-row save would prune
10164    /// the row the flip just moved.
10165    #[tokio::test]
10166    async fn a_held_twin_flip_waits_for_an_in_flight_follow_pass() {
10167        use crate::community::migration;
10168        let (bed, owner, member) = TestBed::new();
10169        let relay = std::sync::Arc::new(MemoryRelay::new());
10170        let (m_v1, v1_cid, v1_channel, v2_hex, twin) = held_twin_bed(&relay, &bed, &owner, &member).await;
10171        let v2_id = twin.identity.community_id;
10172
10173        let signpost = signpost_for(&twin, &v2_hex, owner.keys.public_key().to_hex(), twin.identity.owner_salt, &v1_channel, bed.relays.clone());
10174        crate::db::community::set_migration_pointer(&v1_cid, &migration::build_migration_content(&signpost, None).unwrap()).unwrap();
10175
10176        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
10177        let drive = crate::rt::spawn({
10178            let relay = relay.clone();
10179            let m_v1 = m_v1.clone();
10180            async move { migration::drive_migration(&*relay, &m_v1).await }
10181        });
10182
10183        crate::rt::time::sleep(std::time::Duration::from_millis(300)).await;
10184        assert!(!drive.is_finished(), "the flip must wait for the in-flight follow pass");
10185        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no fence while the lock is held");
10186        assert_eq!(
10187            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10188            Some(v1_cid.as_str()),
10189            "no reparent while the lock is held"
10190        );
10191
10192        drop(held);
10193        assert_eq!(drive.await.unwrap().unwrap().as_deref(), Some(v2_hex.as_str()), "the flip completes once released");
10194        assert_eq!(
10195            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10196            Some(v2_hex.as_str()),
10197            "the channel row is stitched to the twin"
10198        );
10199    }
10200
10201    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
10202    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
10203    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
10204    /// couldn't (that twin had mismatched ids).
10205    #[tokio::test]
10206    async fn owner_wizard_then_member_migrate_and_stitch() {
10207        use crate::community::migration;
10208        let (bed, owner, member) = TestBed::new();
10209        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10210
10211        // Owner holds a v1 community (they created it) with one channel.
10212        bed.swap_to(&owner);
10213        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10214        let v1_cid = v1.id.to_hex();
10215        let v1_channel = v1.channels[0].id.to_hex();
10216        v1.owner_attestation = Some({
10217            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10218                .finalize(&owner.keys).unwrap().as_json()
10219        });
10220        crate::db::community::save_community(&v1).unwrap();
10221
10222        // Run the wizard.
10223        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10224        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
10225            "owner's own client flipped to v2");
10226        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
10227        // primary channel REUSES the v1 channel id.
10228        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
10229            "owner channel stitched to v2");
10230
10231        // A MEMBER holding the same v1 community folds the carrier and migrates.
10232        bed.swap_to(&member);
10233        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10234        // The member's v1 community must be the SAME id + root the owner published under.
10235        m_v1.id = v1.id;
10236        m_v1.server_root_key = v1.server_root_key.clone();
10237        m_v1.channels[0].id = v1.channels[0].id;
10238        m_v1.owner_attestation = v1.owner_attestation.clone();
10239        crate::db::community::save_community(&m_v1).unwrap();
10240
10241        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
10242        // AND auto-drives the flip — the live one-event member experience, no manual step.
10243        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
10244        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
10245        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
10246            "the FOLD ITSELF flipped the member (auto-drive)");
10247        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
10248            "member holds the v2 twin");
10249        // A manual re-drive is an idempotent no-op.
10250        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
10251    }
10252
10253    /// The wizard records the twin in the cross-device community list, like every other v2
10254    /// join/create path. Sibling devices normally discover the twin by folding the carrier
10255    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
10256    /// the list is its only route in.
10257    #[tokio::test]
10258    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
10259        use crate::community::migration;
10260        let (bed, owner, _member) = TestBed::new();
10261        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10262
10263        bed.swap_to(&owner);
10264        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10265        let v1_cid = v1.id.to_hex();
10266        v1.owner_attestation = Some({
10267            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10268                .finalize(&owner.keys).unwrap().as_json()
10269        });
10270        crate::db::community::save_community(&v1).unwrap();
10271
10272        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10273
10274        // The twin is live in the published list, so a fresh/carrier-less device finds it.
10275        let list = fetch_fragments(&bed.relay, &bed.relays).await.unwrap()
10276            .expect("the wizard published a community list").list;
10277        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
10278        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
10279        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
10280        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
10281        assert!(
10282            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
10283            "migration must not tombstone the v1 community"
10284        );
10285    }
10286
10287    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
10288    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
10289    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
10290    /// (the double-mint orphan) and race its flip against the first.
10291    #[tokio::test]
10292    async fn wizard_refuses_while_a_drive_holds_the_claim() {
10293        use crate::community::migration;
10294        let (bed, owner, _member) = TestBed::new();
10295        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10296
10297        bed.swap_to(&owner);
10298        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10299        let v1_cid = v1.id.to_hex();
10300        v1.owner_attestation = Some({
10301            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10302                .finalize(&owner.keys).unwrap().as_json()
10303        });
10304        crate::db::community::save_community(&v1).unwrap();
10305
10306        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
10307        migration::test_hold_drive_claim(&v1_cid);
10308        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
10309        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
10310        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
10311        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
10312        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
10313
10314        // Once the drive releases, the wizard runs normally.
10315        migration::test_release_drive_claim(&v1_cid);
10316        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10317        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
10318    }
10319
10320    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
10321    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
10322    /// channel-less struct — pruning exactly the rows the flip just re-parented).
10323    /// Proves the lock actually serializes rather than being a no-op: with the lock held
10324    /// the wizard cannot reach its flip, and it completes once released.
10325    #[tokio::test]
10326    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
10327        use crate::community::migration;
10328        let (bed, owner, _member) = TestBed::new();
10329        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10330        // Shared across the spawned wizard, so both halves see the same relay state.
10331        let relay = std::sync::Arc::new(MemoryRelay::new());
10332
10333        bed.swap_to(&owner);
10334        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10335        let v1_cid = v1.id.to_hex();
10336        let v1_channel = v1.channels[0].id.to_hex();
10337        v1.owner_attestation = Some({
10338            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10339                .finalize(&owner.keys).unwrap().as_json()
10340        });
10341        crate::db::community::save_community(&v1).unwrap();
10342
10343        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
10344        // the flip runs — exactly what a follow worker would have loaded.
10345        let twin = create_migration_twin(
10346            &*relay, "Guild", bed.relays.clone(), None,
10347            (v1.channels[0].id, "general".to_string()),
10348        ).await.unwrap();
10349        let v2_id = twin.identity.community_id;
10350        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
10351        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
10352
10353        // A follow pass is in flight: it holds the lock across its network stage.
10354        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
10355
10356        let wizard = crate::rt::spawn({
10357            let relay = relay.clone();
10358            let v1 = v1.clone();
10359            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
10360        });
10361
10362        // The wizard runs its network phases but must BLOCK at the flip.
10363        crate::rt::time::sleep(std::time::Duration::from_millis(300)).await;
10364        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
10365        assert!(
10366            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
10367            "the fence must not be stamped while the follow lock is held"
10368        );
10369
10370        // The follow pass finishes; the flip proceeds.
10371        drop(held);
10372        let flipped = wizard.await.unwrap().unwrap();
10373        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
10374        assert_eq!(
10375            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
10376            Some(v2_hex.as_str()),
10377            "the channel row is stitched to the twin, not pruned"
10378        );
10379    }
10380
10381    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
10382    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
10383    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
10384    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
10385    #[tokio::test]
10386    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
10387        use crate::community::migration;
10388        let (bed, owner, banned) = TestBed::new();
10389        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10390
10391        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
10392        bed.swap_to(&owner);
10393        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10394        let v1_cid = v1.id.to_hex();
10395        v1.owner_attestation = Some({
10396            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10397                .finalize(&owner.keys).unwrap().as_json()
10398        });
10399        crate::db::community::save_community(&v1).unwrap();
10400        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
10401
10402        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10403
10404        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
10405        bed.swap_to(&banned);
10406        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10407        m_v1.id = v1.id;
10408        m_v1.server_root_key = v1.server_root_key.clone();
10409        m_v1.channels[0].id = v1.channels[0].id;
10410        m_v1.owner_attestation = v1.owner_attestation.clone();
10411        crate::db::community::save_community(&m_v1).unwrap();
10412        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
10413
10414        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
10415        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
10416        let payload = migration::parse_migration_payload(&raw).unwrap();
10417        assert!(payload.m.is_some());
10418        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
10419        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
10420        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
10421        assert!(
10422            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
10423            "banned member never acquires the v2 twin"
10424        );
10425    }
10426
10427    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
10428    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
10429    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
10430    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
10431    #[tokio::test]
10432    async fn wizard_resume_continues_on_the_same_twin() {
10433        use crate::community::migration;
10434        let (bed, owner, banned) = TestBed::new();
10435        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10436
10437        bed.swap_to(&owner);
10438        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10439        // A second channel + a banned member make the resumed phase-1b tail REAL work.
10440        let mut sibling = v1.channels[0].clone();
10441        sibling.id = crate::community::ChannelId(crate::community::random_32());
10442        sibling.name = "offtopic".into();
10443        v1.channels.push(sibling.clone());
10444        let v1_cid = v1.id.to_hex();
10445        v1.owner_attestation = Some({
10446            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10447                .finalize(&owner.keys).unwrap().as_json()
10448        });
10449        crate::db::community::save_community(&v1).unwrap();
10450        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
10451
10452        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
10453        // BEFORE the sibling channel + banlist clone ever ran.
10454        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
10455        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
10456        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
10457
10458        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
10459        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10460        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
10461        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
10462        assert_eq!(ledger_v2, minted_hex);
10463        assert_eq!(phase, migration::PHASE_FLIPPED);
10464        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
10465        // (folding the twin's control plane yields the banned npub).
10466        assert_eq!(
10467            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
10468            Some(minted_hex.as_str()),
10469            "sibling channel re-parented by the resumed run"
10470        );
10471        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10472        let wire_banlist = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
10473            .await
10474            .unwrap()
10475            .banned;
10476        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
10477            "the resumed banlist clone is folded from the twin's wire control plane");
10478
10479        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
10480        // → the re-run reports SUCCESS and heals, never "already been migrated".
10481        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
10482        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10483        assert_eq!(healed, minted_hex);
10484        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
10485        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
10486
10487        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
10488        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
10489        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
10490        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
10491        // fresh v1 owned by the same owner.
10492        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
10493        let v1b_cid = v1b.id.to_hex();
10494        v1b.owner_attestation = Some({
10495            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
10496                .finalize(&owner.keys).unwrap().as_json()
10497        });
10498        crate::db::community::save_community(&v1b).unwrap();
10499        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
10500        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
10501        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
10502        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
10503        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
10504        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
10505        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
10506    }
10507
10508    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
10509    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
10510    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
10511    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
10512    #[tokio::test]
10513    async fn birth_refound_seeds_an_explicit_roster() {
10514        let (bed, owner, _m) = TestBed::new();
10515        bed.swap_to(&owner);
10516        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10517            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10518        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
10519        // Two strangers who never join — pure seeded members.
10520        let ghost_a = Keys::generate().public_key();
10521        let ghost_b = Keys::generate().public_key();
10522
10523        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10524        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
10525
10526        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
10527        // ever published a Join.
10528        let members = memberlist(&bed.relay, &rolled).await.unwrap();
10529        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
10530        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
10531
10532        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
10533        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
10534        // a fresh twin's snapshot only counts because the owner minted epoch 1.
10535        let _ = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
10536            .expect("the epoch-1 twin verifies from its compacted control plane");
10537
10538        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
10539        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
10540        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
10541        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
10542        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
10543    }
10544
10545    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
10546    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
10547    /// it before the snapshot, so the refound still completes instead of aborting forever.
10548    #[tokio::test]
10549    async fn birth_refound_ignores_a_banned_seed_entry() {
10550        let (bed, owner, _m) = TestBed::new();
10551        bed.swap_to(&owner);
10552        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10553            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10554        let good = Keys::generate().public_key();
10555        let banned = Keys::generate();
10556        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
10557        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
10558        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
10559
10560        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
10561            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
10562        assert_eq!(rolled.root_epoch, Epoch(1));
10563        let members = memberlist(&bed.relay, &rolled).await.unwrap();
10564        assert!(members.contains(&good), "the non-banned seed lands");
10565        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
10566    }
10567
10568    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
10569    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
10570    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
10571    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
10572    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
10573    #[tokio::test]
10574    async fn a_seeded_member_receives_a_later_refound_rekey() {
10575        let (bed, owner, _m) = TestBed::new();
10576        bed.swap_to(&owner);
10577        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
10578            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
10579        let ghost = Keys::generate();
10580        // Birth refound seeds the ghost (never joins, holds no keys).
10581        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
10582        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
10583
10584        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
10585        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
10586        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
10587        // memberlist proves both: the refound saw them as a member and carried them forward, so
10588        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
10589        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
10590        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
10591        assert!(
10592            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
10593            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
10594        );
10595    }
10596
10597    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
10598    /// MANAGE_ROLES there), while a plain member is not.
10599    #[tokio::test]
10600    async fn v1_admin_stays_admin_across_migration() {
10601        use crate::community::migration;
10602        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10603        let (bed, owner, admin) = TestBed::new();
10604        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10605
10606        bed.swap_to(&owner);
10607        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10608        let v1_cid = v1.id.to_hex();
10609        v1.owner_attestation = Some({
10610            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10611                .finalize(&owner.keys).unwrap().as_json()
10612        });
10613        crate::db::community::save_community(&v1).unwrap();
10614        // v1 governance: one Admin role, granted to `admin`.
10615        let admin_role = Role::admin("a1".repeat(32));
10616        let roles = CommunityRoles {
10617            roles: vec![admin_role.clone()],
10618            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
10619        };
10620        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
10621
10622        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10623        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
10624
10625        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
10626        let authority = fetch_authority(&bed.relay, &twin).await;
10627        assert!(
10628            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10629            "the v1 admin is an admin on the v2 twin"
10630        );
10631        assert!(
10632            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
10633            "a non-admin gains no authority"
10634        );
10635    }
10636
10637    /// A device that never folded the dissolution tombstone still heals.
10638    ///
10639    /// Live two-device wedge: the control fold is what seals a migrated-away community,
10640    /// and the boot control probe can veto that fold indefinitely (it is `since`-windowed
10641    /// over the CONTROL plane, while the tombstone lives at the DISSOLVED coordinate). The
10642    /// second device therefore sat UNSEALED, which used to exclude it from the sweep
10643    /// (`dissolved = 1`) AND from the flip retry (no pointer) — the one state that most
10644    /// needed probing was the one nothing probed, so it stayed on v1 forever.
10645    #[tokio::test]
10646    async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
10647        use crate::community::migration;
10648        let (bed, owner, member) = TestBed::new();
10649        bed.swap_to(&owner);
10650
10651        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10652        let v1_cid = v1.id.to_hex();
10653        v1.owner_attestation = Some({
10654            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10655                .finalize(&owner.keys).unwrap().as_json()
10656        });
10657        crate::db::community::save_community(&v1).unwrap();
10658
10659        // The owner migrates on their FIRST device: this publishes the carrier tombstone.
10660        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
10661        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
10662
10663        // A MEMBER's device that holds the v1 community and never folded the tombstone:
10664        // unsealed, pointer-less, unchecked — exactly the wedged shape.
10665        bed.swap_to(&member);
10666        crate::db::community::save_community(&v1).unwrap();
10667        assert!(
10668            !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
10669            "precondition: the wedged device has NOT sealed its v1 row"
10670        );
10671
10672        // It IS a sweep candidate now (the fix); before, `dissolved = 1` excluded it.
10673        assert!(
10674            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10675            "an unsealed, migrated-away v1 must be probed"
10676        );
10677
10678        migration::sweep_dissolved_for_migration(&bed.relay).await;
10679
10680        // The sweep found the carrier, sealed the v1 row, and flipped it to the twin.
10681        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
10682        assert_eq!(
10683            crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
10684            Some(v2_hex.as_str()),
10685            "flipped to the same twin the first device produced"
10686        );
10687        assert!(
10688            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10689            "and the sweep converges — no re-probing forever"
10690        );
10691    }
10692
10693    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
10694    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
10695    /// probe returning only a stranger's record would permanently stop the sweep before the
10696    /// owner's real carrier is ever fetched.
10697    #[tokio::test]
10698    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
10699        use crate::community::migration;
10700        let (bed, owner, stranger) = TestBed::new();
10701
10702        bed.swap_to(&owner);
10703        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10704        let v1_cid = v1.id.to_hex();
10705        v1.owner_attestation = Some({
10706            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
10707                .finalize(&owner.keys).unwrap().as_json()
10708        });
10709        crate::db::community::save_community(&v1).unwrap();
10710
10711        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
10712        // the community is locally sealed (as if folded on an old build) but not yet checked.
10713        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
10714        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
10715        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
10716        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
10717
10718        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
10719        migration::sweep_dissolved_for_migration(&bed.relay).await;
10720        assert!(
10721            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10722            "a stranger-only probe must not converge the sweep"
10723        );
10724
10725        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
10726        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
10727        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
10728        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
10729        migration::sweep_dissolved_for_migration(&bed.relay).await;
10730        assert!(
10731            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
10732            "an owner plain-dissolution converges the sweep"
10733        );
10734    }
10735
10736    /// Wizard preflight refuses before the timelock and for non-owners.
10737    #[tokio::test]
10738    async fn wizard_preflight_gates_timelock_and_ownership() {
10739        use crate::community::migration;
10740        let (bed, owner, _member) = TestBed::new();
10741        bed.swap_to(&owner);
10742        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
10743        v1.owner_attestation = Some({
10744            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
10745                .finalize(&owner.keys).unwrap().as_json()
10746        });
10747        crate::db::community::save_community(&v1).unwrap();
10748
10749        // Before the unlock → refused, nothing published.
10750        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
10751        assert!(err.contains("not unlocked"), "{err}");
10752        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
10753    }
10754
10755    #[tokio::test]
10756    async fn public_link_full_loop() {
10757        let (bed, owner, member) = TestBed::new();
10758
10759        bed.swap_to(&owner);
10760        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
10761        let general = community.channels[0].id;
10762        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
10763        // Mint a shareable link (a non-stock relay so the fragment carries it).
10764        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10765        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
10766        assert!(link.url.contains('#'), "the fragment carries the token");
10767
10768        // Member joins purely from the URL string.
10769        bed.swap_to(&member);
10770        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10771        assert_eq!(joined.id().0, community.id().0);
10772        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
10773    }
10774
10775    #[test]
10776    fn bundle_of_snapshots_the_held_icon() {
10777        let owner = Keys::generate();
10778        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
10779        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
10780        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
10781        c.icon = Some(icon.clone());
10782        let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
10783        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
10784    }
10785
10786    #[test]
10787    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
10788        // follow_rekeys' fetch fan AND streamauth's plane registration share
10789        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
10790        // MUST include archived roots or an AUTH-gated relay never serves the
10791        // rotation crate → the channel stalls at its old epoch.
10792        let (_tmp, _guard, _owner) = init_test_db();
10793        let cur_root = [9u8; 32];
10794        let cid = crate::community::CommunityId([1u8; 32]);
10795        let cid_hex = cid.to_hex();
10796
10797        // No archives yet → just the current root.
10798        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10799        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
10800
10801        // Archive two prior roots (freshest-first ordering is asserted below).
10802        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
10803        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
10804        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10805        assert_eq!(roots[0], cur_root, "current root leads");
10806        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
10807        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
10808        // Freshest-archived-first (epoch 1 before epoch 0).
10809        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
10810
10811        // A stored root equal to the CURRENT one must not duplicate.
10812        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
10813        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10814        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
10815
10816        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
10817        for e in 3..20u64 {
10818            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
10819        }
10820        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
10821        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
10822    }
10823
10824    #[tokio::test]
10825    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
10826        let (bed, owner, member) = TestBed::new();
10827
10828        bed.swap_to(&owner);
10829        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
10830        // The icon lives on the Control Plane, never in the bundle — publish it
10831        // as a metadata edition so the preview must FOLD to see it.
10832        let icon = control::ImageRef {
10833            url: "https://blossom.example/soap".into(),
10834            key: "k".into(),
10835            nonce: "n".into(),
10836            hash: "h".into(),
10837            extra: Default::default(),
10838        };
10839        let mut meta = community.metadata();
10840        meta.icon = Some(icon.clone());
10841        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
10842        // An any-host base — the naddr#fragment payload is domain-agnostic.
10843        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
10844
10845        // A NON-member previews: the real name + the live icon, nothing persisted.
10846        bed.swap_to(&member);
10847        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
10848        assert_eq!(preview.name, "Soapbox");
10849        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
10850        assert!(
10851            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
10852            "previewing must not persist a membership"
10853        );
10854    }
10855
10856    #[tokio::test]
10857    async fn a_previewed_join_reuses_the_verified_fold() {
10858        let (bed, owner, member) = TestBed::new();
10859        bed.swap_to(&owner);
10860        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
10861        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10862
10863        bed.swap_to(&member);
10864        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
10865        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10866        assert_eq!(joined.id().0, community.id().0);
10867        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
10868        // The slot was CONSUMED by the join — proving the handoff path ran (a
10869        // verify re-walk would have left the preview's entry in place).
10870        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
10871    }
10872
10873    #[tokio::test]
10874    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
10875        let (bed, owner, member) = TestBed::new();
10876        bed.swap_to(&owner);
10877        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
10878        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10879
10880        bed.swap_to(&member);
10881        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
10882
10883        // Seed from zero: the stored fold equals the authoritative live fold.
10884        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the seed folds fresh events");
10885        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10886        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
10887        assert!(cursor > 0, "the cursor advanced past zero");
10888        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
10889        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
10890        assert_eq!(stored, live, "stored fold == live fold after the seed");
10891        assert!(stored.contains(&member.keys.public_key()));
10892
10893        // Nothing new on the plane → an idle re-sync folds nothing.
10894        assert!(sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty());
10895
10896        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
10897        bed.swap_to(&owner);
10898        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10899        bed.swap_to(&member);
10900        assert!(!sync_guestbook(&bed.relay, &joined).await.unwrap().is_empty(), "the kick lands incrementally");
10901        assert!(
10902            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
10903            "an owner kick removes the member from the stored fold"
10904        );
10905    }
10906
10907    #[tokio::test]
10908    async fn a_preview_then_revoke_still_refuses_the_join() {
10909        let (bed, owner, member) = TestBed::new();
10910        bed.swap_to(&owner);
10911        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
10912        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10913
10914        // Member previews (warming the verified handoff), THEN the owner revokes.
10915        bed.swap_to(&member);
10916        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
10917        assert_eq!(p.name, "RevokeRace");
10918        bed.swap_to(&owner);
10919        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10920        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10921
10922        // The join MUST refuse: the handoff skips only the root re-verify, never
10923        // the bundle re-fetch that carries the revocation gate.
10924        bed.swap_to(&member);
10925        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10926        assert!(err.contains("revoked"), "got: {err}");
10927    }
10928
10929    #[tokio::test]
10930    async fn a_revoked_link_refuses_to_join() {
10931        let (bed, owner, member) = TestBed::new();
10932        bed.swap_to(&owner);
10933        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
10934        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10935        // Owner retires the link (re-posts the coordinate as a tombstone).
10936        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10937        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
10938
10939        bed.swap_to(&member);
10940        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
10941        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
10942    }
10943
10944    #[tokio::test]
10945    async fn an_expired_direct_invite_refuses_to_join() {
10946        let (bed, owner, member) = TestBed::new();
10947        bed.swap_to(&owner);
10948        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
10949        // Hand-mint an invite that expired in the past.
10950        let inviter = owner.keys.clone();
10951        let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
10952        bundle.expires_at = Some(1_000); // unix ms, long past
10953        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
10954        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10955
10956        bed.swap_to(&member);
10957        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10958        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10959        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
10960    }
10961
10962    #[tokio::test]
10963    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
10964        // The revocation-durability fix: if ANY signer-valid tombstone is among the
10965        // fetched events, refuse — even when a Live bundle is returned FIRST (the
10966        // production union has no newest-first sort, so a stale relay's Live can lead).
10967        let (bed, owner, member) = TestBed::new();
10968        bed.swap_to(&owner);
10969        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
10970        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
10971        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
10972
10973        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
10974        // `events.first()` would join the Live; the scan-all fix must refuse.
10975        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
10976
10977        bed.swap_to(&member);
10978        let err = accept_public_link(&union, &link.url).await.unwrap_err();
10979        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
10980    }
10981
10982    #[test]
10983    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
10984        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
10985        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
10986        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
10987        // cap, which trips even earlier — but the count guard is the real defense
10988        // for the single-layer public-link bundle.)
10989        let owner = Keys::generate();
10990        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
10991        let hex = crate::simd::hex::bytes_to_hex_32;
10992        let root = [0x11u8; 32];
10993        let mut bundle = CommunityInvite {
10994            community_id: hex(&identity.community_id.0),
10995            owner: hex(&identity.owner_xonly),
10996            owner_salt: hex(&identity.owner_salt),
10997            community_root: hex(&root),
10998            root_epoch: 0,
10999            control_pk: None,
11000            channels: vec![],
11001            relays: vec!["wss://r".into()],
11002            name: "X".into(),
11003            icon: None,
11004            expires_at: None,
11005            creator_npub: None,
11006            label: None,
11007            extra: Default::default(),
11008        };
11009        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
11010            .map(|i| {
11011                let mut id = [0u8; 32];
11012                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
11013                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
11014            })
11015            .collect();
11016        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
11017    }
11018
11019    #[tokio::test]
11020    async fn a_join_swap_between_fetch_and_save_lands_in_the_joining_account() {
11021        // A public-link accept fetches, then saves. A swap in that window used to
11022        // abort the join outright; the community is now written to the account
11023        // that clicked the link, and the account swapped in gets nothing.
11024        let (bed, owner, member) = TestBed::new();
11025        bed.swap_to(&owner);
11026        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
11027        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
11028        bed.swap_to(&member);
11029        let joiner = crate::db::current_session();
11030        let swap_relay = SwapMidFetch::arm(&bed.relay);
11031        accept_public_link(&swap_relay, &link.url).await.unwrap();
11032
11033        assert!(
11034            crate::db::with_session(joiner, async {
11035                crate::db::community::load_community_v2(community.id()).unwrap().is_some()
11036            })
11037            .await,
11038            "the join completed for the account that accepted the link"
11039        );
11040    }
11041
11042    #[tokio::test]
11043    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
11044        // The owner is derived from the self-certifying community_id, so the
11045        // memberlist includes them independent of any Guestbook fetch.
11046        let (_tmp, _guard, owner) = init_test_db();
11047        let relay = MemoryRelay::new();
11048        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
11049        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
11050        // still contains the owner.
11051        let empty = MemoryRelay::new();
11052        let members = memberlist(&empty, &community).await.unwrap();
11053        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
11054    }
11055
11056    #[tokio::test]
11057    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
11058        // The mint path can now produce an expiring invite, and the accept gate
11059        // trips on it (end-to-end through the real service, not a hand-built bundle).
11060        let (bed, owner, member) = TestBed::new();
11061        bed.swap_to(&owner);
11062        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
11063        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
11064            .await
11065            .unwrap();
11066
11067        bed.swap_to(&member);
11068        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11069        assert!(
11070            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
11071            "a minted expiring invite refuses past its deadline"
11072        );
11073    }
11074
11075    #[tokio::test]
11076    async fn a_member_who_leaves_drops_from_the_memberlist() {
11077        let (bed, owner, member) = TestBed::new();
11078        bed.swap_to(&owner);
11079        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
11080        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11081
11082        bed.swap_to(&member);
11083        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11084        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11085        // Let the leave land strictly after the join.
11086        crate::rt::time::sleep(std::time::Duration::from_millis(2)).await;
11087        leave_community(&bed.relay, &joined).await.unwrap();
11088
11089        bed.swap_to(&owner);
11090        let members = memberlist(&bed.relay, &community).await.unwrap();
11091        assert!(members.contains(&owner.keys.public_key()));
11092        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
11093    }
11094
11095    #[tokio::test]
11096    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
11097        // Multi-account isolation: after the swap, the member's DB holds nothing
11098        // of the owner's community — the dual-stack storage is per-account.
11099        let (bed, owner, member) = TestBed::new();
11100        bed.swap_to(&owner);
11101        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
11102        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
11103
11104        bed.swap_to(&member);
11105        assert!(
11106            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
11107            "the owner's community must be invisible in the member's account DB"
11108        );
11109        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
11110    }
11111
11112    // ── Live control-follow ──────────────────────────────────────────────────
11113
11114    /// Publish an owner-grammar channel edition straight to the control plane,
11115    /// signed by `signer` (the owner for a legit edit, a stranger for the
11116    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
11117    /// The entity's current head `self_hash` on the relay (highest version wins),
11118    /// so a helper can chain a new edition the way a real owner client does.
11119    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
11120        let group = control::ControlPlane::of(&community).write_group().unwrap();
11121        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11122        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
11123        let mut head: Option<(u64, [u8; 32])> = None;
11124        for w in &wraps {
11125            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11126                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
11127                    head = Some((ed.version, ed.self_hash));
11128                }
11129            }
11130        }
11131        head.map(|(_, h)| h)
11132    }
11133
11134    /// The `vac` a non-owner signer must attach, read off the Grant they were
11135    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
11136    /// real client does via `my_authority_citation`, so the fixtures publish the
11137    /// shape Vector actually emits.
11138    async fn cite_on_relay(
11139        relay: &MemoryRelay,
11140        community: &CommunityV2,
11141        signer: &Keys,
11142    ) -> Option<crate::community::edition::AuthorityCitation> {
11143        if community.owner().ok() == Some(signer.public_key()) {
11144            return None;
11145        }
11146        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
11147        let group = control::ControlPlane::of(&community).write_group().unwrap();
11148        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
11149        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
11150        let mut head: Option<(u64, [u8; 32])> = None;
11151        for w in &wraps {
11152            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
11153                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
11154                    head = Some((ed.version, ed.self_hash));
11155                }
11156            }
11157        }
11158        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
11159    }
11160
11161    async fn publish_channel_edition(
11162        relay: &MemoryRelay,
11163        community: &CommunityV2,
11164        signer: &Keys,
11165        channel_id: &ChannelId,
11166        name: &str,
11167        private: bool,
11168        version: u64,
11169        deleted: bool,
11170    ) {
11171        let group = control::ControlPlane::of(&community).write_group().unwrap();
11172        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
11173        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
11174        let content = serde_json::to_string(&meta).unwrap();
11175        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
11176        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11177        relay.publish(&wrap, &community.relays).await.unwrap();
11178    }
11179
11180    /// A channel edition at an EXPLICIT time, so a paging test can order the plane —
11181    /// the default helper stamps everything 1_000, which leaves newest-first paging
11182    /// arbitrary and any "buried edition" scenario vacuous.
11183    #[allow(clippy::too_many_arguments)]
11184    async fn publish_channel_edition_at(
11185        relay: &MemoryRelay,
11186        community: &CommunityV2,
11187        signer: &Keys,
11188        channel_id: &ChannelId,
11189        name: &str,
11190        version: u64,
11191        at_secs: u64,
11192    ) {
11193        let group = control::ControlPlane::of(community).write_group().unwrap();
11194        let meta = control::ChannelMetadata { name: name.into(), private: false, deleted: None, ..Default::default() };
11195        let content = serde_json::to_string(&meta).unwrap();
11196        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, None, &content, at_secs, None);
11197        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
11198        relay.publish(&wrap, &community.relays).await.unwrap();
11199    }
11200
11201    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
11202    /// to the current relay head like a real owner client.
11203    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
11204        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
11205    }
11206
11207    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
11208    /// relay-side newest-first ordering (paging/eviction scenarios).
11209    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
11210        let group = control::ControlPlane::of(&community).write_group().unwrap();
11211        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
11212        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
11213        let content = serde_json::to_string(&meta).unwrap();
11214        let cite = cite_on_relay(relay, community, signer).await;
11215        let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, cite.as_ref());
11216        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
11217        relay.publish(&wrap, &community.relays).await.unwrap();
11218    }
11219
11220    #[test]
11221    fn metadata_apply_captures_undriven_fields_for_republish() {
11222        let owner = Keys::generate();
11223        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
11224        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
11225        let general = held.channels[0].id;
11226
11227        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
11228        let mut custom = serde_json::Map::new();
11229        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
11230        let mut extra = serde_json::Map::new();
11231        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
11232        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
11233        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
11234        assert_eq!(held.meta_custom, Some(custom.clone()));
11235        assert_eq!(held.meta_extra, extra);
11236        // …and the next local edit's base document republishes them verbatim.
11237        assert_eq!(held.metadata().custom, Some(custom));
11238        assert_eq!(held.metadata().extra, held.meta_extra);
11239
11240        // Same contract for a vsk-2 channel head (voice included).
11241        let mut ch_custom = serde_json::Map::new();
11242        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
11243        let ch_meta = control::ChannelMetadata {
11244            name: "general".into(),
11245            private: false,
11246            voice: Some(true),
11247            deleted: None,
11248            custom: Some(ch_custom.clone()),
11249            extra: Default::default(),
11250        };
11251        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
11252        let ch = held.channel(&general).unwrap();
11253        assert_eq!(ch.voice, Some(true));
11254        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
11255        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
11256        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
11257        assert_eq!(rename.custom, Some(ch_custom));
11258    }
11259
11260    #[test]
11261    fn community_metadata_apply_sets_and_clears_images() {
11262        let owner = Keys::generate();
11263        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
11264        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
11265
11266        let icon = control::ImageRef {
11267            url: "https://blossom.example/i".into(),
11268            key: "k".into(),
11269            nonce: "n".into(),
11270            hash: "h".into(),
11271            extra: Default::default(),
11272        };
11273        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
11274        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
11275        assert_eq!(held.icon.as_ref(), Some(&icon));
11276
11277        // An edition is the FULL document: a head without the icon removes it.
11278        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
11279        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
11280        assert_eq!(held.icon, None);
11281    }
11282
11283    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
11284    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
11285        publish_role_at(relay, community, signer, role, version, 1_000).await
11286    }
11287
11288    /// Page filler: a signed edition that occupies a slot in the pager's window and
11289    /// nothing else. It skips the head/citation relay scans `publish_role` does, which
11290    /// are linear in plane size and make a 500-edition test quadratic.
11291    async fn publish_filler_edition(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
11292        let group = control::ControlPlane::of(&community).write_group().unwrap();
11293        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
11294        let content = crate::community::v2::roles::role_content_json(role).unwrap();
11295        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, None, &content, ts, None);
11296        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
11297        relay.publish(&wrap, &community.relays).await.unwrap();
11298    }
11299
11300    /// `publish_role` with an explicit wall clock. Every other helper stamps 1_000, so a
11301    /// test needing more than one page of history must spread its editions out — a page
11302    /// that is entirely one second wide is a wall `until` cannot step past, and the
11303    /// pager correctly refuses rather than paging forever.
11304    async fn publish_role_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64, ts: u64) {
11305        let group = control::ControlPlane::of(&community).write_group().unwrap();
11306        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
11307        let prev = head_hash_on_relay(relay, community, &role_id).await;
11308        let content = crate::community::v2::roles::role_content_json(role).unwrap();
11309        let cite = cite_on_relay(relay, community, signer).await;
11310        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, ts, cite.as_ref());
11311        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(ts)).unwrap();
11312        relay.publish(&wrap, &community.relays).await.unwrap();
11313    }
11314
11315    /// Hand-rotate a community view to `(new_root, epoch)`, minting the fresh
11316    /// split pair a real base rotation would (CORD-06 §3) — the control_root is
11317    /// the new root itself, which is fine for a test.
11318    fn rotate_view(c: &CommunityV2, new_root: [u8; 32], epoch: u64) -> CommunityV2 {
11319        let mut r = c.clone();
11320        r.community_root = new_root;
11321        r.root_epoch = Epoch(epoch);
11322        r.control_root = Some(new_root);
11323        r.control_pk = Some(crate::community::v2::derive::control_signer_group_key(&new_root, r.id(), Epoch(epoch)).pk());
11324        r
11325    }
11326
11327    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
11328    /// Like a compliant granter it delivers the staff write key inside the Grant
11329    /// (CORD-04 §3) whenever the community view holds one — the recipient's
11330    /// derive check drops it when it isn't owed, so attaching unconditionally
11331    /// keeps the helper simple while exercising the real delivery pipeline.
11332    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
11333        let group = control::ControlPlane::of(&community).write_group().unwrap();
11334        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
11335        let prev = head_hash_on_relay(relay, community, &eid).await;
11336        let grant = MemberGrant { member: member.to_hex(), role_ids };
11337        let control_wrap = community.control_root.filter(|_| community.control_pk.is_some()).map(|cr| {
11338            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(signer.secret_key(), member).unwrap();
11339            let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(community.root_epoch, &cr).as_bytes()).unwrap();
11340            base64_simd::STANDARD.encode_to_string(&payload)
11341        });
11342        let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, control_wrap).unwrap();
11343        let cite = cite_on_relay(relay, community, signer).await;
11344        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
11345        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11346        relay.publish(&wrap, &community.relays).await.unwrap();
11347    }
11348
11349    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
11350    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
11351        let group = control::ControlPlane::of(&community).write_group().unwrap();
11352        let eid = crate::community::v2::derive::banlist_locator(community.id());
11353        let prev = head_hash_on_relay(relay, community, &eid).await;
11354        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
11355        let cite = cite_on_relay(relay, community, signer).await;
11356        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
11357        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11358        relay.publish(&wrap, &community.relays).await.unwrap();
11359    }
11360
11361    fn admin_role(role_id: &str, perms: u64) -> Role {
11362        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0, extra: Default::default() }
11363    }
11364
11365    /// Hand-roll a LEGACY (pre-split) community exactly as a pre-0.4.2 build
11366    /// created one: the whole Control Plane keyed by the community_root
11367    /// derivation, no control pair anywhere. Publishes the two genesis-shaped
11368    /// editions at the LEGACY address, persists, and folds to seed floors.
11369    async fn create_legacy_community(relay: &MemoryRelay, owner: &Keys, name: &str, relays: Vec<String>) -> CommunityV2 {
11370        let identity = control::CommunityIdentity::mint(&owner.public_key());
11371        let root = crate::community::random_32();
11372        let group = control_group_key(&root, &identity.community_id, Epoch(0));
11373        let meta = control::CommunityMetadata { name: name.into(), relays: relays.clone(), ..Default::default() };
11374        let meta_rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &identity.community_id.0, 1, None, &serde_json::to_string(&meta).unwrap(), 1_000, None);
11375        let ch_id = ChannelId(crate::community::random_32());
11376        let ch = control::ChannelMetadata { name: "general".into(), private: false, ..Default::default() };
11377        let ch_rumor = control::build_edition_rumor(owner.public_key(), vsk::CHANNEL_METADATA, &ch_id.0, 1, None, &serde_json::to_string(&ch).unwrap(), 1_000, None);
11378        for r in [&meta_rumor, &ch_rumor] {
11379            let (w, _) = control::seal_control_edition(r, &group, owner, Timestamp::from_secs(1_000)).unwrap();
11380            relay.publish(&w, &relays).await.unwrap();
11381        }
11382        let community = CommunityV2 {
11383            identity,
11384            community_root: root,
11385            root_epoch: Epoch(0),
11386            control_pk: None,
11387            control_root: None,
11388            name: name.into(),
11389            description: None,
11390            icon: None,
11391            banner: None,
11392            meta_custom: None,
11393            meta_extra: Default::default(),
11394            relays,
11395            channels: vec![ChannelV2 { id: ch_id, name: "general".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() }],
11396            dissolved: false,
11397            created_at_ms: 1_000_000,
11398        };
11399        crate::db::community::save_community_v2(&community).unwrap();
11400        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11401        let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &root);
11402        let _ = follow_control(relay, &community).await;
11403        crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
11404    }
11405
11406    #[tokio::test]
11407    async fn a_refounding_mints_the_split_and_delivers_the_pair_by_staffness() {
11408        // CORD-06 §1/§3 end to end: the rotation mints a fresh control pair, a
11409        // STAFF recipient's blob carries the secret (136), a plain member's only
11410        // the address (104), the compaction publishes ONLY at the new split
11411        // address, and each follower adopts exactly what its blob delivered.
11412        let (bed, owner, admin) = TestBed::new();
11413        bed.swap_to(&owner);
11414        let community = create_community(&bed.relay, "SplitRefound", bed.relays.clone(), None).await.unwrap();
11415        assert!(community.control_pk.is_some() && community.control_root.is_some(), "genesis mints the split");
11416
11417        // A staff admin (granted; the Grant delivers the wrap) and a plain
11418        // member (Guestbook Join only — a recipient with no rank).
11419        let rid = "d1".repeat(32);
11420        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11421        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
11422        let plain = Keys::generate();
11423        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11424        let join = guestbook::build_join_rumor(plain.public_key(), None, 1_000);
11425        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &plain, Timestamp::from_secs(1_000)).unwrap();
11426        bed.relay.publish(&w, &community.relays).await.unwrap();
11427
11428        // The admin joins and folds — adopting the write key from their Grant.
11429        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
11430        bed.swap_to(&admin);
11431        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
11432        assert_eq!(joined.control_pk, community.control_pk, "the bundle handed over the address");
11433        assert_eq!(joined.control_root, None, "a bundle never carries the secret");
11434        let _ = follow_control(&bed.relay, &joined).await;
11435        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
11436        assert_eq!(joined.control_root, community.control_root, "the Grant's control_wrap delivered the secret");
11437
11438        // The owner refounds: a FRESH pair beside the new root.
11439        bed.swap_to(&owner);
11440        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
11441        assert_eq!(refounded.root_epoch, Epoch(1));
11442        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some());
11443        assert_ne!(refounded.control_pk, community.control_pk, "the pair rolls with the root");
11444
11445        // Blob forms by staff-ness: the admin's carries the secret, the plain
11446        // member's only the pk (CORD-06 §1).
11447        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11448        let chunks = fetch_rekey_chunks(&bed.relay, &community.relays, &base_group).await.unwrap();
11449        let rotation = &rekey::collect_rotations(&chunks)[0];
11450        async fn open_as(rotation: &rekey::Rotation, community_id: &crate::community::CommunityId, keys: &Keys) -> rekey::BaseKeyDelivery {
11451            let blob = rekey::find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), &keys.public_key().to_bytes(), rotation.scope, rotation.new_epoch).expect("a blob for every recipient");
11452            let signer = crate::signer::ActiveSigner::Keys(keys.clone());
11453            rekey::open_base_blob(&signer, &rotation.rotator, community_id, Epoch(1), blob).await.unwrap()
11454        }
11455        let admin_delivery = open_as(rotation, community.id(), &admin.keys).await;
11456        assert_eq!(admin_delivery.control_root, refounded.control_root, "staff blob = 136, secret included");
11457        let plain_delivery = open_as(rotation, community.id(), &plain).await;
11458        assert_eq!(plain_delivery.control_pk.map(|p| PublicKey::from_slice(p.as_slice()).unwrap()), refounded.control_pk);
11459        assert_eq!(plain_delivery.control_root, None, "member blob = 104, never the secret");
11460
11461        // The compaction lives ONLY at the new split address — never mirrored to
11462        // the new epoch's legacy-derived address (CORD-06 §3).
11463        let legacy_new = control_group_key(&refounded.community_root, community.id(), Epoch(1));
11464        let q = |pk: String| Query { kinds: vec![stream::KIND_WRAP], authors: vec![pk], limit: Some(50), ..Default::default() };
11465        assert!(bed.relay.fetch(&q(legacy_new.pk_hex()), &community.relays).await.unwrap().is_empty(), "no legacy mirror");
11466        assert!(!bed.relay.fetch(&q(refounded.control_pk.unwrap().to_hex()), &community.relays).await.unwrap().is_empty(), "the compaction rides the split address");
11467
11468        // The admin's follow adopts the whole pair from their 136-byte blob.
11469        bed.swap_to(&admin);
11470        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
11471        let adopted = follow.updated.expect("the admin adopts the rotation");
11472        assert_eq!(adopted.root_epoch, Epoch(1));
11473        assert_eq!(adopted.control_pk, refounded.control_pk);
11474        assert_eq!(adopted.control_root, refounded.control_root, "staff crossing a rotation get the new secret in the blob");
11475    }
11476
11477    #[tokio::test]
11478    async fn a_rescue_lets_a_stranded_member_walk_every_missed_hop() {
11479        // A member forgotten by two consecutive rotations is wedged at epoch 0: their
11480        // client derives base_rekey_group(root_0, cid, 1), finds no blob, and can never
11481        // even compute where epoch 2 lives. The rescue publishes the blobs that should
11482        // have existed, correlated into the ORIGINAL rotations — then the walk just works.
11483        let (_tmp, _guard, _owner) = init_test_db();
11484        let relay = MemoryRelay::new();
11485        let community = create_community(&relay, "Stranded", vec!["wss://r".into()], None).await.unwrap();
11486        let stranded = Keys::generate();
11487
11488        // Two rotations that never vend to `stranded` (they are in no fold: never
11489        // joined the wire, never posted, not in the local store).
11490        let e1 = refound_community(&relay, &community, &[]).await.unwrap();
11491        let e2 = refound_community(&relay, &e1, &[]).await.unwrap();
11492        assert_eq!(e2.root_epoch, Epoch(2));
11493
11494        // Wedged: no blob for them at the only plane they can derive.
11495        let hop1 = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11496        let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11497        let r1 = &rekey::collect_rotations(&chunks)[0];
11498        assert!(
11499            rekey::find_my_blob(&r1.blobs, &r1.rotator.to_bytes(), &stranded.public_key().to_bytes(), r1.scope, r1.new_epoch).is_none(),
11500            "genuinely stranded before the rescue"
11501        );
11502
11503        let report = rescue_stranded_members(&relay, &e2, &[stranded.public_key()]).await.unwrap();
11504        assert_eq!(report.hops, 2, "one catch-up per missed hop");
11505        assert_eq!(report.hops_skipped, 0);
11506
11507        // Walk exactly as a client would: derive each hop's plane from the PREVIOUS
11508        // root, find our blob inside the correlated rotation, open, adopt, repeat.
11509        let signer = crate::signer::ActiveSigner::Keys(stranded.clone());
11510        let mut held_root = community.community_root;
11511        for e in 1..=2u64 {
11512            let group = base_rekey_group_key(&held_root, community.id(), Epoch(e));
11513            let chunks = fetch_rekey_chunks(&relay, &community.relays, &group).await.unwrap();
11514            let rotations = rekey::collect_rotations(&chunks);
11515            let rot = rotations
11516                .iter()
11517                .find(|r| {
11518                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &stranded.public_key().to_bytes(), r.scope, r.new_epoch).is_some()
11519                })
11520                .expect("a rotation now carries our blob");
11521            let blob = rekey::find_my_blob(&rot.blobs, &rot.rotator.to_bytes(), &stranded.public_key().to_bytes(), rot.scope, rot.new_epoch).unwrap();
11522            let delivery = rekey::open_base_blob(&signer, &rot.rotator, community.id(), Epoch(e), blob).await.unwrap();
11523            held_root = delivery.new_root;
11524        }
11525        assert_eq!(held_root, e2.community_root, "the walk ends on the live root");
11526
11527        // Merge safety on DEPLOYED clients: the catch-up extended the original chunk
11528        // set (disjoint indices), so everything folds as ONE complete rotation and no
11529        // original recipient's blob was displaced — a slot collision would fold as
11530        // their removal.
11531        let chunks = fetch_rekey_chunks(&relay, &community.relays, &hop1).await.unwrap();
11532        let rotations = rekey::collect_rotations(&chunks);
11533        assert_eq!(rotations.len(), 1, "catch-up merged into the original rotation, not a fork");
11534        assert!(rotations[0].is_complete(), "the extended set still reads complete");
11535        let owner_pk = e2.owner().unwrap();
11536        assert!(
11537            rekey::find_my_blob(&rotations[0].blobs, &rotations[0].rotator.to_bytes(), &owner_pk.to_bytes(), rotations[0].scope, rotations[0].new_epoch).is_some(),
11538            "the original recipient's blob survived the extension"
11539        );
11540
11541        // And the outside world counts them again: the snapshot addendum seeded them
11542        // onto the current epoch's guestbook plane.
11543        let wire = wire_guestbook_members(&relay, &e2).await.unwrap();
11544        assert!(wire.contains(&stranded.public_key()), "the invite page's fold now includes them");
11545    }
11546
11547    #[tokio::test]
11548    async fn a_non_owner_refounders_snapshot_seeds_the_roster() {
11549        // CORD-02 §5: a snapshot is honored "only from the npub whose Refounding
11550        // minted that epoch". Vector read that as the OWNER always, so a moderation
11551        // bot's containment rolled the epoch and vended the keys while its survivor
11552        // list was thrown away — every client then folded its own accumulated Joins,
11553        // keeping members the containment had cut and disagreeing with each other.
11554        let (_tmp, _guard, _owner) = init_test_db();
11555        let relay = MemoryRelay::new();
11556        let community = create_community(&relay, "MintedBy", vec!["wss://r".into()], None).await.unwrap();
11557        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11558        let owner = community.owner().unwrap();
11559
11560        // An admin (NOT the owner) refounded to epoch 1 — this device verified and
11561        // adopted that rotation, so it knows who minted it.
11562        let admin = Keys::generate();
11563        let mut at_epoch_1 = community.clone();
11564        at_epoch_1.root_epoch = Epoch(1);
11565        record_epoch_minter(&cid_hex, 1, &admin.public_key().to_hex());
11566
11567        // Someone the containment cut: joined at the old epoch, never banned, and
11568        // absent from the survivor list the refounder published.
11569        let cut = Keys::generate().public_key();
11570        let join = guestbook::GuestbookEvent {
11571            epoch: None, observed_at: None, rumor_id: [11u8; 32],
11572            entry: guestbook::GuestbookEntry::Join { member: cut, invited_by: None, at_ms: 1_000 },
11573        };
11574        let snapshot = |by: &Keys, id: u8| guestbook::GuestbookEvent {
11575            epoch: Some(1), observed_at: None, rumor_id: [id; 32],
11576            entry: guestbook::GuestbookEntry::Snapshot {
11577                refounder: by.public_key(),
11578                members: vec![owner],           // survivors: the cut member is NOT here
11579                snapshot_id: [id; 32],
11580                chunk: (1, 1),
11581                at_ms: 5_000,                   // newer than the Join it supersedes
11582            },
11583        };
11584
11585        // A stranger's snapshot must change nothing — authority is the minter, not
11586        // whoever manages to write a 3312.
11587        let stranger = Keys::generate();
11588        crate::db::community::set_guestbook(&cid_hex, &[join.clone(), snapshot(&stranger, 0x51)], 6).unwrap();
11589        assert!(
11590            stored_memberlist(&at_epoch_1).unwrap().contains(&cut),
11591            "a snapshot from an npub that minted nothing seeds no roster"
11592        );
11593
11594        // The actual refounder's snapshot IS the guest list.
11595        crate::db::community::set_guestbook(&cid_hex, &[join, snapshot(&admin, 0x52)], 6).unwrap();
11596        let members = stored_memberlist(&at_epoch_1).unwrap();
11597        assert!(
11598            !members.contains(&cut),
11599            "the refounder's survivor list is adopted, so a cut member stops being one"
11600        );
11601        assert!(members.contains(&owner), "a survivor the snapshot names stays");
11602    }
11603
11604    /// A moderation report quotes what was said, so it may only reach someone who
11605    /// could have read the room it was said in. Community power is NOT that
11606    /// question — BAN says what a moderator may do to people, never which rooms
11607    /// they may see — and a bot holding every key would otherwise forward a
11608    /// private channel's contents to whoever holds the ban bit.
11609    #[tokio::test]
11610    async fn reading_a_private_channel_needs_its_own_role_not_community_power() {
11611        let (bed, owner, mod_without_access) = TestBed::new();
11612        bed.swap_to(&owner);
11613        let community = create_community(&bed.relay, "Rooms", bed.relays.clone(), None).await.unwrap();
11614        let public = community.channels[0].id;
11615        let private = create_private_channel(&bed.relay, &community, "back-room").await.unwrap();
11616        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11617        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11618
11619        use nostr_sdk::prelude::ToBech32;
11620        let them = mod_without_access.keys.public_key();
11621        let them_npub = them.to_bech32().unwrap();
11622
11623        // Give them the fullest COMMUNITY powers there are. It must buy nothing
11624        // in a room they were never admitted to.
11625        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa7; 32]);
11626        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
11627        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![admin_rid], 1).await;
11628        follow_control(&bed.relay, &community).await.unwrap();
11629        assert!(
11630            crate::VectorCore.member_has_permission(&cid_hex, &them_npub, Permissions::BAN).unwrap(),
11631            "they really do hold BAN"
11632        );
11633
11634        let pub_hex = crate::simd::hex::bytes_to_hex_32(&public.0);
11635        let priv_hex = crate::simd::hex::bytes_to_hex_32(&private.0);
11636        assert!(
11637            crate::VectorCore.member_can_read_channel(&cid_hex, &pub_hex, &them_npub).unwrap(),
11638            "a public channel is the community's"
11639        );
11640        assert!(
11641            !crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11642            "BAN is not a key: community power must never open a private room"
11643        );
11644
11645        // The access role is what opens it — and it carries no powers at all.
11646        grant_channel_access(&bed.relay, &community, &private, &them).await.unwrap();
11647        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11648        let _ = follow_control(&bed.relay, &community).await;
11649        assert!(
11650            crate::VectorCore.member_can_read_channel(&cid_hex, &priv_hex, &them_npub).unwrap(),
11651            "the channel-scoped grant is what admits them"
11652        );
11653    }
11654
11655    /// An empty retain means "cut nobody", never "keep nobody".
11656    ///
11657    /// The console's "Rotate keys" sends an empty retain when nothing is unticked.
11658    /// `members_to_remove` reads that correctly and removes nobody — but the same
11659    /// empty list also became an ALLOW-LIST, and an allow-list keeps only the
11660    /// rotating device and the owner. So a plain rotation vended to two people and
11661    /// stranded everyone else at the previous epoch, permanently, presenting as
11662    /// members vanishing some time later nowhere near the button that did it.
11663    ///
11664    /// The two paths are pinned here against each other, because they take the same
11665    /// empty list and must disagree about what it means.
11666    #[tokio::test]
11667    async fn an_empty_retain_cuts_nobody_rather_than_keeping_nobody() {
11668        let (bed, owner, member) = TestBed::new();
11669        bed.swap_to(&owner);
11670        let community = create_community(&bed.relay, "EmptyRetain", bed.relays.clone(), None).await.unwrap();
11671
11672        // A third party who is neither the rotator nor the owner — the only kind of
11673        // member an empty allow-list silently drops.
11674        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11675        bed.swap_to(&member);
11676        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11677        bed.swap_to(&owner);
11678        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()));
11679
11680        // The plain reading: cut nobody, vend to everybody. This is what the button
11681        // must reach. (The allow-list reading of the same empty list is covered by
11682        // `a_rotation_that_would_strand_staff_refuses_to_publish`.)
11683        refound_community(&bed.relay, &community, &[]).await.expect("cutting nobody must succeed");
11684        assert!(
11685            vended_to(&bed, &community, &member.keys).await,
11686            "a plain rotation must vend to a member who is neither the rotator nor the owner"
11687        );
11688    }
11689
11690    /// The console's rotate button reaches the "cut nobody" path, not the allow-list
11691    /// one. Pinned from source: the two are one `if` apart and read the same empty
11692    /// list in opposite directions, which is exactly how this shipped.
11693    #[test]
11694    fn an_empty_retain_does_not_become_an_allow_list() {
11695        let src = include_str!("../../lib.rs");
11696        let at = src.find("pub async fn purge_and_refound").expect("purge_and_refound");
11697        let body = &src[at..at + 3000];
11698        let guard = body.find("if keep.is_empty()").expect("an empty retain must branch away from the allow-list");
11699        let retaining = body.find("refound_community_retaining").expect("the allow-list call");
11700        assert!(guard < retaining, "the branch has to come BEFORE the allow-list call");
11701    }
11702
11703    /// If the recipients would strand a granted member, publish NOTHING.
11704    ///
11705    /// A member missed at a hop is stranded there permanently, and every other
11706    /// client goes on reading them as staff — silent, irreversible without the
11707    /// rescue primitive, and only noticed because a human saw the member list
11708    /// shrink. So the rotation refuses instead, exactly as the entity carry-forward
11709    /// already refuses a floored head it cannot serve.
11710    #[tokio::test]
11711    async fn a_rotation_that_would_strand_staff_refuses_to_publish() {
11712        let (bed, owner, staffer) = TestBed::new();
11713        bed.swap_to(&owner);
11714        let community = create_community(&bed.relay, "NoSilentSever", bed.relays.clone(), None).await.unwrap();
11715        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11716        let them = staffer.keys.public_key();
11717
11718        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb6; 32]);
11719        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11720        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11721        follow_control(&bed.relay, &community).await.unwrap();
11722
11723        // An operator purge whose allow-list forgets an admin: the recipient set
11724        // ends up without somebody the roster still grants a role to.
11725        let err = refound_community_retaining(&bed.relay, &community, &[], &[owner.keys.public_key()])
11726            .await
11727            .expect_err("must refuse rather than strand staff");
11728        assert!(
11729            err.contains("stranded"),
11730            "the refusal has to say what it protected, got: {err}"
11731        );
11732
11733        // And nothing was published: the epoch did not move.
11734        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11735        assert_eq!(held.root_epoch, community.root_epoch, "no state published");
11736        assert!(!vended_to(&bed, &community, &staffer.keys).await, "no half-rotation left on the plane");
11737        let _ = cid_hex;
11738    }
11739
11740    /// The backstop protects a SILENT granted member. An ACTIVE one is the gap.
11741    ///
11742    /// `fold_members` seeds granted members with `observed.entry(pk).or_insert(0)`,
11743    /// and ts 0 is what dodges `stale_pre_refound`. A staffer who actually talks
11744    /// already sits in `observed` with a REAL timestamp, so `or_insert` leaves it
11745    /// alone — and if some earlier refounding's snapshot omitted them while their
11746    /// last word predates its cut, they are suppressed despite holding a grant.
11747    ///
11748    /// That is Sentire, 2026-08-26: admin, constantly posting, vended no key.
11749    #[tokio::test]
11750    async fn a_granted_member_who_talks_is_not_dropped_by_an_old_snapshot() {
11751        let (bed, owner, staffer) = TestBed::new();
11752        bed.swap_to(&owner);
11753        let community = create_community(&bed.relay, "TalkingAdmin", bed.relays.clone(), None).await.unwrap();
11754        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11755        let them = staffer.keys.public_key();
11756
11757        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb5; 32]);
11758        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11759        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11760        follow_control(&bed.relay, &community).await.unwrap();
11761
11762        // They joined and spoke — so `observed` holds a REAL timestamp for them,
11763        // not the 0 the grant backstop would have inserted.
11764        let spoke_at = 5_000u64;
11765        let join = guestbook::GuestbookEvent {
11766            epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x31; 32],
11767            entry: guestbook::GuestbookEntry::Join { member: them, invited_by: None, at_ms: spoke_at },
11768        };
11769        // A PRIOR refounding whose snapshot omitted them, cutting after they spoke.
11770        let snapshot = guestbook::GuestbookEvent {
11771            epoch: Some(community.root_epoch.0), observed_at: None, rumor_id: [0x32; 32],
11772            entry: guestbook::GuestbookEntry::Snapshot {
11773                refounder: owner.keys.public_key(),
11774                members: vec![owner.keys.public_key()],   // them: omitted
11775                snapshot_id: [0x32; 32],
11776                chunk: (1, 1),
11777                at_ms: spoke_at + 1_000,
11778            },
11779        };
11780        crate::db::community::set_guestbook(&cid_hex, &[join, snapshot], 9).unwrap();
11781
11782        assert!(
11783            stored_memberlist(&community).unwrap().contains(&them),
11784            "a grant is proof of membership — an old snapshot must not shed the staff it forgot"
11785        );
11786
11787        let _ = refound_community(&bed.relay, &community, &[]).await.unwrap();
11788        assert!(
11789            vended_to(&bed, &community, &staffer.keys).await,
11790            "and the rotation must vend to them, or they are stranded holding a grant"
11791        );
11792    }
11793
11794    /// A GRANT is proof of membership, so a rotation must vend to it.
11795    ///
11796    /// Sentire's shape, 2026-08-26: an admin whose Join had been superseded by an
11797    /// earlier refounding's snapshot fell out of BOTH folds the recipient union is
11798    /// built from, held a valid admin grant the whole time, and was vended no key.
11799    /// It could then only derive `held + 1`, so it was stranded permanently while
11800    /// still appearing to be staff to everyone else.
11801    ///
11802    /// `fold_members` already treats a grant as consensus-complete proof — "this is
11803    /// what keeps a Refounding from severing a lurking admin". The rotation has to
11804    /// agree with it.
11805    #[tokio::test]
11806    async fn a_rotation_vends_to_a_granted_member_the_folds_forgot() {
11807        let (bed, owner, staffer) = TestBed::new();
11808        bed.swap_to(&owner);
11809        let community = create_community(&bed.relay, "LurkingAdmin", bed.relays.clone(), None).await.unwrap();
11810        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11811        let them = staffer.keys.public_key();
11812
11813        // Staff by GRANT, with no Join anywhere — the lurking admin the backstop
11814        // exists for. Never posted, never announced.
11815        let rid = crate::simd::hex::bytes_to_hex_32(&[0xb4; 32]);
11816        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11817        publish_grant(&bed.relay, &community, &owner.keys, &them, vec![rid], 1).await;
11818        follow_control(&bed.relay, &community).await.unwrap();
11819        assert!(
11820            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&them.to_hex()),
11821            "they hold the grant"
11822        );
11823
11824        // An empty guestbook is the whole point: neither fold can see them.
11825        crate::db::community::set_guestbook(&cid_hex, &[], 1).unwrap();
11826
11827        let _rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11828
11829        // The blob is the answer. A member absent from the rotation's recipients is
11830        // stranded at the previous epoch for good — they can only ever derive
11831        // `held + 1`, so no later rotation reaches them.
11832        assert!(
11833            vended_to(&bed, &community, &staffer.keys).await,
11834            "a rotation removing nobody must vend to a granted member, or it severs its own staff"
11835        );
11836    }
11837
11838    /// "Rotate keys" with nobody unticked must remove NOBODY — and must vend to
11839    /// everyone, not merely leave them on a list. A rotation that forgets a
11840    /// member strands them at the previous epoch permanently: they can only
11841    /// derive `held + 1`, so no later rotation can ever reach them.
11842    #[tokio::test]
11843    async fn a_rotation_that_removes_nobody_vends_to_everybody() {
11844        let (bed, owner, member) = TestBed::new();
11845        bed.swap_to(&owner);
11846        let community = create_community(&bed.relay, "KeepAll", bed.relays.clone(), None).await.unwrap();
11847
11848        // A second member joins for real, via the invite path.
11849        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
11850        bed.swap_to(&member);
11851        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
11852        bed.swap_to(&owner);
11853        let before = memberlist(&bed.relay, &community).await.unwrap();
11854        assert!(before.contains(&member.keys.public_key()), "present before the rotation");
11855
11856        // Rotate keeping everyone — the empty `removed` the console sends when
11857        // nothing is unticked.
11858        let rotated = refound_community(&bed.relay, &community, &[]).await.unwrap();
11859        assert_eq!(rotated.root_epoch.0, community.root_epoch.0 + 1, "the epoch rolled");
11860
11861        let after = memberlist(&bed.relay, &community).await.unwrap();
11862        assert!(
11863            after.contains(&member.keys.public_key()),
11864            "a rotation removing nobody must keep everybody: {before:?} -> {after:?}"
11865        );
11866
11867        // And the member must actually be able to WALK to the new epoch, which is
11868        // the half a memberlist cannot show.
11869        bed.swap_to(&member);
11870        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11871        let follow = follow_rekeys(&bed.relay, &held, &crate::db::current_session()).await.unwrap();
11872        assert!(!follow.self_removed, "they were not excluded");
11873        let now = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11874        assert_eq!(now.root_epoch.0, rotated.root_epoch.0, "they adopted the new epoch — a blob was vended to them");
11875    }
11876
11877    /// A revocation has to reach the NETWORK, not just the revoker.
11878    ///
11879    /// Every other client decides who is staff by folding the control plane, so
11880    /// an owner whose demotion never published is the only person who believes
11881    /// it happened — and the demoted admin keeps every power everywhere else,
11882    /// which is the worst possible direction for this to fail in.
11883    #[tokio::test]
11884    async fn revoking_admin_publishes_where_everyone_else_reads_it() {
11885        let (bed, owner, member) = TestBed::new();
11886        bed.swap_to(&owner);
11887        let community = create_community(&bed.relay, "Demotion", bed.relays.clone(), None).await.unwrap();
11888        let them = member.keys.public_key();
11889        let them_hex = them.to_hex();
11890
11891        grant_admin(&bed.relay, &community, &them).await.unwrap();
11892        let view = fetch_authority(&bed.relay, &community).await;
11893        assert!(view.roles.is_admin(&them_hex), "granted, and readable from the plane");
11894
11895        revoke_admin(&bed.relay, &community, &them).await.unwrap();
11896
11897        // The plane, NOT the local row: this is the copy every peer folds.
11898        let view = fetch_authority(&bed.relay, &community).await;
11899        assert!(
11900            !view.roles.is_admin(&them_hex),
11901            "the demotion must be on the plane, or only the owner believes it"
11902        );
11903
11904        // And the same answer through the public API a bot or client would ask.
11905        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11906        use nostr_sdk::prelude::ToBech32;
11907        assert!(
11908            !crate::VectorCore
11909                .member_has_permission(&cid_hex, &them.to_bech32().unwrap(), crate::community::roles::Permissions::BAN)
11910                .unwrap(),
11911            "a demoted admin holds no powers"
11912        );
11913    }
11914
11915    /// Authority must fail CLOSED. A bot gating a chat command on this is the
11916    /// only thing standing between "a moderator asked" and "anyone in the room
11917    /// asked", so an unfolded, empty or unreadable roster has to authorise
11918    /// nobody — `has_permission` finds no grant and denies. The owner is the one
11919    /// exception, and is answered without consulting the roster at all.
11920    #[tokio::test]
11921    async fn permission_is_refused_to_everyone_the_roster_does_not_name() {
11922        let (_tmp, _guard, _owner) = init_test_db();
11923        let relay = MemoryRelay::new();
11924        let community = create_community(&relay, "Authority", vec!["wss://r".into()], None).await.unwrap();
11925        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11926        use nostr_sdk::prelude::ToBech32;
11927        let owner_npub = community.owner().unwrap().to_bech32().unwrap();
11928        let stranger = Keys::generate().public_key().to_bech32().unwrap();
11929
11930        use crate::community::roles::Permissions;
11931        for p in [Permissions::KICK, Permissions::BAN, Permissions::MANAGE_ROLES] {
11932            assert!(
11933                !crate::VectorCore.member_has_permission(&cid_hex, &stranger, p).unwrap(),
11934                "an npub the roster never granted anything holds nothing"
11935            );
11936            assert!(
11937                crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, p).unwrap(),
11938                "the owner is supreme without a grant to cite"
11939            );
11940        }
11941
11942        // A banned npub holds nothing, whatever a stale grant still says.
11943        crate::db::community::set_community_banlist(&cid_hex, &[community.owner().unwrap().to_hex()], 1).unwrap();
11944        let banned_owner_is_still_owner =
11945            crate::VectorCore.member_has_permission(&cid_hex, &owner_npub, Permissions::BAN).unwrap();
11946        assert!(banned_owner_is_still_owner, "the owner cannot be locked out of their own community");
11947
11948        // And a malformed npub is an error, never a silent grant.
11949        assert!(crate::VectorCore.member_has_permission(&cid_hex, "not-an-npub", Permissions::BAN).is_err());
11950    }
11951
11952    #[tokio::test]
11953    async fn a_refound_keeps_a_quiet_member_the_network_fold_forgot() {
11954        // The network fold only sees the current guestbook + the recent posting window,
11955        // so a quiet member whose Join lives on a buried epoch vanishes from it — and a
11956        // rotation that trusts the fold alone silently sheds them. Being forgotten must
11957        // not be a removal: only the explicit `removed` list is.
11958        let (_tmp, _guard, _owner) = init_test_db();
11959        let relay = MemoryRelay::new();
11960        let community = create_community(&relay, "QuietOnes", vec!["wss://r".into()], None).await.unwrap();
11961        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11962
11963        // A member the LOCAL store remembers (an old synced Join) but the WIRE has no
11964        // trace of: never posted, Join never published on the current plane.
11965        let quiet = Keys::generate().public_key();
11966        let ev = guestbook::GuestbookEvent { epoch: None, observed_at: None,
11967            rumor_id: [7u8; 32],
11968            entry: guestbook::GuestbookEntry::Join { member: quiet, invited_by: None, at_ms: 1_000 },
11969        };
11970        crate::db::community::set_guestbook(&cid_hex, std::slice::from_ref(&ev), 1).unwrap();
11971        assert!(stored_memberlist(&community).unwrap().contains(&quiet), "locally known");
11972        assert!(!memberlist(&relay, &community).await.unwrap().contains(&quiet), "network fold has forgotten them");
11973
11974        let rotated = refound_community(&relay, &community, &[]).await.unwrap();
11975
11976        // They made the new epoch's snapshot — the wire now carries them, so an outside
11977        // reader counts them and their client has a rekey blob to walk forward on.
11978        let wire = wire_guestbook_members(&relay, &rotated).await.unwrap();
11979        assert!(wire.contains(&quiet), "the quiet member survived the rotation onto the wire");
11980
11981        // The explicit removed list still removes, union or no union.
11982        let rotated2 = refound_community(&relay, &rotated, &[quiet]).await.unwrap();
11983        let wire2 = wire_guestbook_members(&relay, &rotated2).await.unwrap();
11984        assert!(!wire2.contains(&quiet), "an explicit removal is honoured");
11985    }
11986
11987    #[tokio::test]
11988    async fn revoking_a_husk_prunes_the_local_row_instead_of_erroring_forever() {
11989        // The list is per-creator and cross-device: another device (or Armada) can
11990        // retire a link this one still holds a row for. That row carries no signer_sk,
11991        // so no revocation can ever be built from it — the old behaviour errored with
11992        // "no such link in the invite list" BEFORE the local delete, leaving an entry
11993        // the owner could neither use nor remove.
11994        let (_tmp, _guard, _owner) = init_test_db();
11995        let relay = MemoryRelay::new();
11996        let community = create_community(&relay, "Husks", vec!["wss://r".into()], None).await.unwrap();
11997        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11998
11999        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12000        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
12001        assert_eq!(crate::db::community::list_public_invites(&cid_hex).unwrap().len(), 1);
12002
12003        // "Another device" retires the link: republish the invite list without it.
12004        let signer = crate::signer::active_signer().unwrap();
12005        let my_pk = me_pk().unwrap();
12006        let mut list = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
12007        list.entries.retain(|e| e.token != token_hex);
12008        let ev = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.unwrap();
12009        relay.publish(&ev, &community.relays).await.unwrap();
12010
12011        // This device's revoke now completes by pruning, not by erroring.
12012        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
12013        assert!(
12014            crate::db::community::list_public_invites(&cid_hex).unwrap().is_empty(),
12015            "the husk row is gone — the panel can no longer show a link nobody can use or remove"
12016        );
12017
12018        // An unreadable list is NOT proof of absence: only a fetched list may prune.
12019        // (Guarded by the ok_or above the husk branch; a fresh row + an empty relay
12020        // would hit "no invite list found", never the prune.)
12021    }
12022
12023    #[tokio::test]
12024    async fn revoking_the_last_link_privatizes_and_rotates_but_an_earlier_revoke_does_not() {
12025        // CORD-06 §3: converting a Public Community to Private is a Refounding
12026        // trigger — revoking stops NEW acquisitions, only the rotation cuts off
12027        // everyone who already fetched the bundle. v1 does this; v2 shipped
12028        // without it. Revoking a link while ANOTHER stays live is not the
12029        // conversion, and must not burn an epoch.
12030        let (_tmp, _guard, _owner) = init_test_db();
12031        let relay = MemoryRelay::new();
12032        let community = create_community(&relay, "Privatize", vec!["wss://r".into()], None).await.unwrap();
12033        assert_eq!(community.root_epoch, Epoch(0));
12034
12035        let a = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12036        let b = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12037        assert!(community_is_public(&relay, &community).await, "two live links = Public");
12038
12039        // Revoking ONE of two: still Public, still epoch 0 — no rotation.
12040        revoke_public_link(&relay, &community, &crate::simd::hex::bytes_to_hex_16(&a.token)).await.unwrap();
12041        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12042        assert_eq!(held.root_epoch, Epoch(0), "a link remains live — not the Public→Private conversion");
12043        assert!(community_is_public(&relay, &held).await, "still Public");
12044
12045        // Revoking the LAST one privatizes AND rotates.
12046        revoke_public_link(&relay, &held, &crate::simd::hex::bytes_to_hex_16(&b.token)).await.unwrap();
12047        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12048        assert!(!community_is_public(&relay, &held).await, "no live links = Private");
12049        assert_eq!(held.root_epoch, Epoch(1), "privatizing rotated the base key");
12050        // The rotation is a real one: the new epoch carries the split pair and
12051        // the old root is retired, so a link-joined lurker's key is now dead.
12052        assert!(held.control_pk.is_some(), "the privatize rotation mints the split like any base rotation");
12053        assert_ne!(held.community_root, community.community_root, "the base key actually rolled");
12054    }
12055
12056    #[tokio::test]
12057    async fn a_legacy_community_upgrades_as_a_side_effect_of_its_next_refounding() {
12058        // CORD-06 §3: a compliant Rotator performing ANY base rotation MUST mint
12059        // the split — a pre-split community upgrades with nobody deciding to.
12060        let (_tmp, _guard, owner) = init_test_db();
12061        let relay = MemoryRelay::new();
12062        let community = create_legacy_community(&relay, &owner, "Legacy", vec!["wss://r".into()]).await;
12063        assert!(community.control_pk.is_none(), "starts pre-split");
12064
12065        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
12066        assert_eq!(refounded.root_epoch, Epoch(1));
12067        assert!(refounded.control_pk.is_some() && refounded.control_root.is_some(), "the rotation minted the split");
12068        // And the persisted row agrees (the pair survives a reload).
12069        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12070        assert_eq!(reloaded.control_pk, refounded.control_pk);
12071        assert_eq!(reloaded.control_root, refounded.control_root);
12072    }
12073
12074    #[tokio::test]
12075    async fn an_old_build_deletion_recovers_from_the_stale_bundle_across_the_split() {
12076        // The 0.4.0/0.4.1 upgrade path: those builds hit the widened base blob,
12077        // conclude a false removal, and DELETE the community locally. Recovery on
12078        // upgrade is the Community-List resurrection: re-accept the STALE-epoch
12079        // material (the old plane is still served), then walk the rekey chain
12080        // forward — now parsing the 104-byte form — to the current epoch.
12081        let (bed, owner, member) = TestBed::new();
12082        bed.swap_to(&owner);
12083        let community = create_legacy_community(&bed.relay, &owner.keys, "Recover", bed.relays.clone()).await;
12084        let stale_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12085
12086        // The member joins at the legacy epoch 0.
12087        bed.swap_to(&member);
12088        let joined = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
12089        assert!(joined.control_pk.is_none());
12090
12091        // The owner bans nobody but rotates (any refound upgrades to the split).
12092        bed.swap_to(&owner);
12093        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
12094
12095        // The member's OLD build falsely self-removes and deletes the community.
12096        bed.swap_to(&member);
12097        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12098        crate::db::community::delete_community(&cid_hex).unwrap();
12099        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
12100
12101        // Upgrade-recovery: re-accept the stale epoch-0 material (what
12102        // sync_community_list does from the surviving 13302 entry)...
12103        let readopted = accept_parked_invite(&bed.relay, &stale_bundle, None).await.unwrap();
12104        assert_eq!(readopted.root_epoch, Epoch(0), "re-anchored at the stale epoch");
12105        // ...then the walk crosses the split rotation and adopts the pair.
12106        let follow = follow_rekeys(&bed.relay, &readopted, &crate::db::current_session()).await.unwrap();
12107        assert!(!follow.self_removed, "never a false removal on this build");
12108        let healed = follow.updated.expect("the walk adopts the split rotation");
12109        assert_eq!(healed.root_epoch, Epoch(1));
12110        assert_eq!(healed.control_pk, refounded.control_pk, "the member reads the upgraded plane");
12111        assert_eq!(healed.control_root, None, "a plain member never receives the secret");
12112        // And the upgraded control plane folds for them (the compacted state).
12113        let folded = follow_control(&bed.relay, &healed).await;
12114        assert!(folded.is_ok(), "the recovered member folds the split plane: {folded:?}");
12115    }
12116
12117    #[tokio::test]
12118    async fn an_unopenable_blob_at_my_locator_never_concludes_removal() {
12119        // CORD-06 §2: removal = NO blob for my locator across a complete
12120        // rotation. A blob that EXISTS but won't open (a future width, a rotator
12121        // bug) must park — turning it into a self-removal is exactly how the
12122        // pre-split builds deleted communities on the first widened rotation.
12123        let (bed, owner, member) = TestBed::new();
12124        bed.swap_to(&owner);
12125        let community = create_community(&bed.relay, "Garbled", bed.relays.clone(), None).await.unwrap();
12126        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12127        bed.swap_to(&member);
12128        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12129
12130        // The owner mints a complete, authorized rotation whose blob AT THE
12131        // MEMBER'S LOCATOR is garbage (undecryptable), owner's own blob real.
12132        bed.swap_to(&owner);
12133        let new_epoch = Epoch(1);
12134        let new_root = [0x4Du8; 32];
12135        let control_root = [0x4Eu8; 32];
12136        let control_pk = super::super::derive::control_signer_group_key(&control_root, community.id(), new_epoch).pk();
12137        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
12138        let garbled = rekey::RekeyBlob {
12139            locator: rekey::blob_locator(&owner.keys.public_key().to_bytes(), &member.keys.public_key().to_bytes(), RekeyScope::Root, new_epoch),
12140            wrapped: "bm90LWEtcmVhbC1uaXA0NC1wYXlsb2Fk".into(),
12141        };
12142        let own = rekey::build_base_blob(
12143            &crate::signer::ActiveSigner::Keys(owner.keys.clone()),
12144            &owner.keys.public_key().to_bytes(),
12145            &owner.keys.public_key(),
12146            new_epoch,
12147            &new_root,
12148            &control_pk.to_bytes(),
12149            Some(&control_root),
12150        )
12151        .await
12152        .unwrap();
12153        let base_group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
12154        let chunks = rekey::build_rekey_chunks_local(&owner.keys, &base_group, RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[garbled, own], 2_000, None, false).unwrap();
12155        for c in &chunks {
12156            bed.relay.publish(c, &community.relays).await.unwrap();
12157        }
12158
12159        // The member's follow PARKS: no adoption, and NEVER a removal.
12160        bed.swap_to(&member);
12161        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
12162        assert!(!follow.self_removed, "an unopenable blob at my locator is not an exclusion");
12163        assert!(follow.updated.is_none(), "nothing adopted from an unreadable blob");
12164        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "the community survives");
12165        // …and the park is LEGIBLE. A silent park is indistinguishable from "no
12166        // rotation happened", which is what makes an undecryptable blob planted
12167        // at a protected member's locator an abusable severing tool.
12168        assert!(follow.wedged, "an unopenable key delivery must be surfaced, not swallowed");
12169    }
12170
12171    #[tokio::test]
12172    async fn a_control_wrap_is_refused_on_a_stale_epoch_or_a_non_deriving_secret() {
12173        // What the "no rank-gating on adoption" argument rests on (CORD-04 §3):
12174        // a wrap is adopted only if its secret derives to the control_pk held
12175        // for the epoch named INSIDE its ciphertext. That derive check is the
12176        // load-bearing gate (revert-verified); the explicit epoch comparison
12177        // beside it is defense in depth and can't be isolated, since deriving
12178        // at a stale epoch already misses the held address. Compaction re-wraps
12179        // a Grant head verbatim across Refoundings, so stale wraps are a
12180        // structural certainty, not an edge case.
12181        let (bed, owner, member) = TestBed::new();
12182        bed.swap_to(&owner);
12183        let community = create_community(&bed.relay, "AdoptGate", bed.relays.clone(), None).await.unwrap();
12184        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, None, None, None)).unwrap();
12185        bed.swap_to(&member);
12186        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
12187        assert!(joined.control_root.is_none());
12188
12189        // Build a Grant edition carrying `control_wrap` with a chosen plaintext.
12190        let wrap_for = |epoch: Epoch, secret: &[u8; 32]| {
12191            let ck = nostr_sdk::prelude::nip44::v2::ConversationKey::derive(owner.keys.secret_key(), &member.keys.public_key()).unwrap();
12192            let ct = crate::community::cipher::encrypt_with_random_nonce(&ck, super::rekey::control_wrap_b64(epoch, secret).as_bytes()).unwrap();
12193            base64_simd::STANDARD.encode_to_string(&ct)
12194        };
12195        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.keys.public_key().to_bytes());
12196        let ed_with = |wrap: String| {
12197            let grant = MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![] };
12198            let content = crate::community::v2::roles::grant_content_json_with_wrap(&grant, Some(wrap)).unwrap();
12199            let rumor = control::build_edition_rumor(owner.keys.public_key(), vsk::GRANT, &eid, 1, None, &content, 1_000, None);
12200            control::parse_edition_rumor(&rumor).unwrap()
12201        };
12202
12203        // (a) A wrap for a DIFFERENT epoch — the real secret, wrong epoch.
12204        let real = community.control_root.unwrap();
12205        let stale = ed_with(wrap_for(Epoch(joined.root_epoch.0 + 1), &real));
12206        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&stale)).await, None, "a stale-epoch wrap is refused");
12207
12208        // (b) Right epoch, a secret that does NOT derive to the held pk.
12209        let forged = ed_with(wrap_for(joined.root_epoch, &[0x66u8; 32]));
12210        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&forged)).await, None, "a non-deriving secret is refused");
12211
12212        // (c) The real secret at the held epoch IS adopted — the gates above
12213        // reject on their own merits, not because adoption is broken.
12214        let good = ed_with(wrap_for(joined.root_epoch, &real));
12215        assert_eq!(adopt_my_control_wrap(&joined, std::slice::from_ref(&good)).await, Some(real), "the true wrap adopts");
12216    }
12217
12218    #[test]
12219    fn the_vault_carries_the_split_and_the_twin_strips_the_secret() {
12220        // CORD-02 §8: the list carries every key its holder has — the address
12221        // for all, the secret for staff. The migration `m` (member-bound) MUST
12222        // strip the secret.
12223        let owner = Keys::generate();
12224        let g = control::genesis(&owner, control::CommunityMetadata { name: "Vault".into(), ..Default::default() }, 1_000).unwrap();
12225        let c = CommunityV2::from_genesis(&g, "Vault", None, vec!["wss://r".into()], 0);
12226        let jm = join_material(&c);
12227        assert_eq!(jm.control_pk, c.control_pk.map(|p| p.to_hex()));
12228        assert_eq!(jm.control_root, Some(crate::simd::hex::bytes_to_hex_32(&g.control_root)));
12229        let twin = twin_join_material(&c);
12230        assert_eq!(twin.control_pk, jm.control_pk, "members get the address");
12231        assert_eq!(twin.control_root, None, "members never get the secret");
12232        let bundle = material_to_invite(&jm);
12233        assert_eq!(bundle.control_pk, jm.control_pk, "a rehydrating bundle carries the address");
12234    }
12235
12236    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
12237    // any control edition) must not be able to SUPPRESS a role or grant by forging a
12238    // higher version at its coordinate. Owner-only signers mask this entirely, so every
12239    // attacker below signs as a NON-owner member.
12240
12241    #[tokio::test]
12242    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
12243        let (bed, owner, attacker) = TestBed::new();
12244        bed.swap_to(&owner);
12245        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
12246        let victim = Keys::generate().public_key();
12247        grant_admin(&bed.relay, &community, &victim).await.unwrap();
12248
12249        // The admin role sits at a deterministic, publicly-computable coordinate.
12250        let admin_rid = fetch_authority(&bed.relay, &community)
12251            .await
12252            .roles
12253            .roles
12254            .iter()
12255            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
12256            .unwrap()
12257            .role_id
12258            .clone();
12259        // Attacker forges v2 of that exact role, stripping its powers.
12260        publish_role(
12261            &bed.relay,
12262            &community,
12263            &attacker.keys,
12264            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0, extra: Default::default() },
12265            2,
12266        )
12267        .await;
12268
12269        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12270        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
12271        assert!(
12272            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
12273            "the floor advances only to the AUTHORIZED head (owner v1)"
12274        );
12275        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
12276    }
12277
12278    #[tokio::test]
12279    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
12280        let (bed, owner, attacker) = TestBed::new();
12281        bed.swap_to(&owner);
12282        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
12283        let victim = Keys::generate();
12284        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
12285
12286        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
12287        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
12288
12289        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12290        assert!(
12291            authority.roles.is_admin(&victim.public_key().to_hex()),
12292            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
12293        );
12294    }
12295
12296    #[tokio::test]
12297    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
12298        let (bed, owner, attacker) = TestBed::new();
12299        bed.swap_to(&owner);
12300        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
12301        let victim = Keys::generate().public_key();
12302        grant_admin(&bed.relay, &community, &victim).await.unwrap();
12303
12304        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
12305        // unauthorized, so the post-authorize cap never sees them.
12306        for i in 0u8..6 {
12307            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
12308            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12309        }
12310
12311        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
12312        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
12313        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
12314    }
12315
12316    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
12317    /// roster + banlist — two clients converge iff these match.
12318    fn authority_fingerprint(a: &AuthoritySet) -> String {
12319        let mut roles = a.roles.roles.clone();
12320        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
12321        let mut grants = a.roles.grants.clone();
12322        for g in &mut grants {
12323            g.role_ids.sort();
12324        }
12325        grants.sort_by(|x, y| x.member.cmp(&y.member));
12326        let banned: Vec<&String> = a.banned.iter().collect();
12327        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
12328    }
12329
12330    fn folded_role<'a>(view: &'a AuthorityView, id: &str) -> &'a crate::community::roles::Role {
12331        view.roles.role(id).unwrap_or_else(|| panic!("role {id} folded"))
12332    }
12333
12334    #[tokio::test]
12335    async fn roles_are_created_at_the_bottom_edited_in_place_and_reordered() {
12336        use crate::community::roles::{Permissions, RoleScope};
12337        let (bed, owner, _m) = TestBed::new();
12338        bed.swap_to(&owner);
12339        let community = create_community(&bed.relay, "Roles", bed.relays.clone(), None).await.unwrap();
12340
12341        let a = create_role(&bed.relay, &community, "Alpha", 0, Permissions::KICK, RoleScope::Server).await.unwrap();
12342        let b = create_role(&bed.relay, &community, "Beta", 0, 0, RoleScope::Server).await.unwrap();
12343        let c = create_role(&bed.relay, &community, "Gamma", 0, 0, RoleScope::Server).await.unwrap();
12344        let view = fetch_authority(&bed.relay, &community).await;
12345        let pos = |v: &AuthorityView, id: &str| folded_role(v, id).position;
12346        assert!(pos(&view, &a) < pos(&view, &b) && pos(&view, &b) < pos(&view, &c), "each new role lands beneath the last");
12347        assert!(pos(&view, &a) >= 1, "no role claims the owner's position");
12348
12349        edit_role(&bed.relay, &community, &b, "Beta Prime", 0x59fcb3, Permissions::BAN, RoleScope::Server).await.unwrap();
12350        let view = fetch_authority(&bed.relay, &community).await;
12351        let edited = folded_role(&view, &b);
12352        assert_eq!((edited.name.as_str(), edited.color, edited.permissions.0), ("Beta Prime", 0x59fcb3, Permissions::BAN));
12353        assert_eq!(edited.position, pos(&view, &b), "an edit keeps the role where it was");
12354
12355        let moved = reorder_roles(&bed.relay, &community, &[c.clone(), a.clone(), b.clone()]).await.unwrap();
12356        assert_eq!(moved, 3, "every role whose position changed is republished");
12357        let view = fetch_authority(&bed.relay, &community).await;
12358        assert!(pos(&view, &c) < pos(&view, &a) && pos(&view, &a) < pos(&view, &b), "the fold holds the requested order");
12359        assert_eq!(pos(&view, &c), 1, "renumbered from just beneath the owner");
12360
12361        let again = reorder_roles(&bed.relay, &community, &[c, a, b]).await.unwrap();
12362        assert_eq!(again, 0, "the same order again publishes nothing");
12363    }
12364
12365    #[tokio::test]
12366    async fn new_roles_stack_above_the_access_band_and_never_share_a_position() {
12367        use crate::community::roles::{Permissions, RoleScope};
12368        let (bed, owner, _m) = TestBed::new();
12369        bed.swap_to(&owner);
12370        let community = create_community(&bed.relay, "Band", bed.relays.clone(), None).await.unwrap();
12371        create_private_channel(&bed.relay, &community, "secret").await.unwrap();
12372        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12373        let a = create_role(&bed.relay, &held, "A", 0, Permissions::empty().0, RoleScope::Server).await.unwrap();
12374        let b = create_role(&bed.relay, &held, "B", 0, Permissions::empty().0, RoleScope::Server).await.unwrap();
12375        let view = fetch_authority(&bed.relay, &held).await;
12376        let (pa, pb) = (folded_role(&view, &a).position, folded_role(&view, &b).position);
12377        assert!(pa < pb, "each new role lands beneath the last");
12378        assert!(pb < ACCESS_ROLE_POSITION, "and above the access band, never in the slot beneath it");
12379    }
12380
12381    #[tokio::test]
12382    async fn a_members_role_set_is_written_whole_and_the_owner_is_never_a_target() {
12383        use crate::community::roles::{Permissions, RoleScope};
12384        let (bed, owner, _m) = TestBed::new();
12385        bed.swap_to(&owner);
12386        let community = create_community(&bed.relay, "Grants", bed.relays.clone(), None).await.unwrap();
12387        let a = create_role(&bed.relay, &community, "A", 0, Permissions::KICK, RoleScope::Server).await.unwrap();
12388        let b = create_role(&bed.relay, &community, "B", 0, 0, RoleScope::Server).await.unwrap();
12389        let member = Keys::generate().public_key();
12390
12391        set_member_roles(&bed.relay, &community, &member, vec![a.clone(), b.clone()]).await.unwrap();
12392        let view = fetch_authority(&bed.relay, &community).await;
12393        let held: Vec<String> = view.roles.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
12394        assert!(held.contains(&a) && held.contains(&b), "both roles granted");
12395
12396        set_member_roles(&bed.relay, &community, &member, vec![b.clone()]).await.unwrap();
12397        let view = fetch_authority(&bed.relay, &community).await;
12398        let held: Vec<String> = view.roles.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
12399        assert_eq!(held, vec![b.clone()], "the set replaces, it does not append");
12400
12401        let err = set_member_roles(&bed.relay, &community, &owner.keys.public_key(), vec![a]).await.unwrap_err();
12402        assert!(err.contains("owner"), "the owner is never a role target: {err}");
12403    }
12404
12405    #[tokio::test]
12406    async fn losing_the_last_role_that_opens_a_channel_rotates_it_and_keeping_one_does_not() {
12407        use crate::community::roles::{Permissions, RoleScope};
12408        let (bed, owner, _m) = TestBed::new();
12409        bed.swap_to(&owner);
12410        let community = create_community(&bed.relay, "Keys", bed.relays.clone(), None).await.unwrap();
12411        let chan = create_private_channel(&bed.relay, &community, "secret").await.unwrap();
12412        let chan_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
12413        let reload = || crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12414        let epoch = |c: &CommunityV2| c.channel(&chan).unwrap().epoch.0;
12415
12416        let held = reload();
12417        let access = fetch_authority(&bed.relay, &held).await.roles.channel_role_ids(&chan_hex);
12418        assert!(!access.is_empty(), "the private channel came with its access role");
12419        let second = create_role(&bed.relay, &held, "Also Secret", 0, Permissions::empty().0, RoleScope::Channel(chan_hex.clone())).await.unwrap();
12420        let member = Keys::generate().public_key();
12421
12422        set_member_roles(&bed.relay, &reload(), &member, vec![access[0].clone(), second.clone()]).await.unwrap();
12423        let start = epoch(&reload());
12424
12425        // Drop one of the two: still entitled, so nobody is cut and nothing rotates.
12426        set_member_roles(&bed.relay, &reload(), &member, vec![second.clone()]).await.unwrap();
12427        assert_eq!(epoch(&reload()), start, "a kept role still opens the channel");
12428
12429        // Drop the last: the channel rotates without them.
12430        set_member_roles(&bed.relay, &reload(), &member, vec![]).await.unwrap();
12431        assert_eq!(epoch(&reload()), start + 1, "losing the last access role severs them");
12432    }
12433
12434    #[test]
12435    fn a_role_author_adds_only_bits_they_hold_and_acts_only_beneath_themselves() {
12436        use crate::community::roles::Permissions;
12437        let moderator = RoleAuthor {
12438            me_hex: "m".into(),
12439            owner_hex: "o".into(),
12440            is_owner: false,
12441            rank: 3,
12442            grantable: Permissions::MANAGE_ROLES | Permissions::KICK,
12443        };
12444        assert!(moderator.may_add(0, Permissions::KICK), "a bit they hold");
12445        assert!(!moderator.may_add(0, Permissions::BAN), "a bit they lack");
12446        assert!(moderator.may_add(Permissions::BAN, Permissions::BAN | Permissions::KICK), "an existing bit may stay");
12447        assert!(moderator.may_add(Permissions::BAN, 0), "and may always be taken away");
12448        assert!(moderator.outranks(4) && !moderator.outranks(3) && !moderator.outranks(1), "strictly beneath only");
12449        let owner = RoleAuthor { is_owner: true, rank: 0, grantable: u64::MAX, ..moderator };
12450        assert!(owner.may_add(0, u64::MAX) && owner.outranks(1));
12451    }
12452
12453    #[tokio::test]
12454    async fn the_v2_authority_fold_is_order_independent() {
12455        // THE core consensus property: two honest clients that receive the SAME
12456        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
12457        // authorized roster + banlist (author-aware select_authorized + banlist
12458        // fold + cap, all deterministic). A divergence here would fork the
12459        // community's moderation state between honest members.
12460        let (bed, owner, _a) = TestBed::new();
12461        bed.swap_to(&owner);
12462        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
12463
12464        // A rich control plane: two admins, an extra role, two grants (one of them a
12465        // grant to a member the owner then bans), a banlist, a rename, a channel.
12466        let admin1 = Keys::generate().public_key();
12467        let admin2 = Keys::generate().public_key();
12468        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
12469        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
12470        let mod_rid = "5c".repeat(32);
12471        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
12472        let member = Keys::generate().public_key();
12473        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
12474        let banned_member = Keys::generate().public_key();
12475        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
12476        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
12477        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
12478        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
12479        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
12480
12481        let editions = fetch_control(&bed.relay, &community).await;
12482        let floors = load_floors(&community);
12483        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
12484
12485        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
12486
12487        // Fold under many arrival permutations: reversed, and several deterministic
12488        // rotations/interleavings. Every one must match the baseline.
12489        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
12490        let mut rev = editions.clone();
12491        rev.reverse();
12492        orders.push(rev);
12493        for shift in [1usize, 3, 5, 7] {
12494            let n = editions.len();
12495            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
12496        }
12497        // A deterministic "shuffle": interleave from both ends.
12498        let mut zip = Vec::with_capacity(editions.len());
12499        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
12500        while lo <= hi {
12501            zip.push(editions[lo as usize].clone());
12502            if lo != hi {
12503                zip.push(editions[hi as usize].clone());
12504            }
12505            lo += 1;
12506            hi -= 1;
12507        }
12508        orders.push(zip);
12509
12510        for (i, order) in orders.iter().enumerate() {
12511            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
12512            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
12513        }
12514        // Sanity: the fingerprint reflects real state (the banned member is out, the
12515        // honest admins are in).
12516        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
12517        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
12518    }
12519
12520    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
12521    struct FetchErrors(MemoryRelay);
12522    #[async_trait::async_trait]
12523    impl crate::community::transport::Transport for FetchErrors {
12524        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
12525        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12526            self.0.publish(e, r).await
12527        }
12528        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
12529            Err("relay down".to_string())
12530        }
12531        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12532            self.0.publish_durable(e, r).await
12533        }
12534    }
12535
12536    #[tokio::test]
12537    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
12538        let (bed, owner, victim) = TestBed::new();
12539        bed.swap_to(&owner);
12540        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
12541        let victim_hex = victim.keys.public_key().to_hex();
12542        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
12543        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12544        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
12545
12546        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
12547        // return an empty banlist (which would silently un-ban on withheld data).
12548        let down = FetchErrors(MemoryRelay::new());
12549        let view = fetch_authority(&down, &community).await;
12550        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
12551    }
12552
12553    #[tokio::test]
12554    async fn the_fold_pages_past_a_full_window_to_find_the_roster_it_tracks() {
12555        // Soapbox's actual shape. The grants that define the admin list are OLDER than
12556        // one page of a busy control plane. An entity absent from the window dangles no
12557        // link, so it raises no gap, so the pager called the very first page "coherent"
12558        // and stopped — every fold offered zero grants, forever, and only the
12559        // completeness guard retaining the cache kept the admin list alive. That made
12560        // the cache the last surviving copy of the roster rather than a cache.
12561        //
12562        // Coherent is not complete. The fold must page until what it tracks is found.
12563        let (bed, owner, _m) = TestBed::new();
12564        bed.swap_to(&owner);
12565        let community = create_community(&bed.relay, "DeepFold", bed.relays.clone(), None).await.unwrap();
12566        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12567        let rid = "e7".repeat(32);
12568        let admin = Keys::generate();
12569
12570        // The roster, at t=1_000 — the OLDEST thing on the plane.
12571        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12572        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12573        follow_control(&bed.relay, &community).await.unwrap();
12574        assert!(
12575            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12576            "baseline: the admin folds while its editions are still in reach",
12577        );
12578
12579        // Bury it under MORE than one page of strictly NEWER control traffic.
12580        for i in 0..(FOLLOW_PAGE + 50) {
12581            publish_channel_edition_at(
12582                &bed.relay, &community, &owner.keys,
12583                &ChannelId(crate::simd::hex::hex_to_bytes_32(&format!("{:064x}", i + 1))),
12584                &format!("ch{i}"), 1, 2_000 + i as u64,
12585            ).await;
12586        }
12587
12588        // Forget the grant's FLOOR. Re-establishing it is the observable proof that
12589        // the fold actually reached the grant edition, rather than merely retaining a
12590        // cached roster it could not re-derive.
12591        let grant_eid = crate::simd::hex::bytes_to_hex_32(
12592            &crate::community::v2::derive::grant_locator(community.id(), &admin.public_key().to_bytes()),
12593        );
12594        crate::db::community::delete_edition_head(&cid_hex, &grant_eid).unwrap();
12595        assert!(
12596            crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_none(),
12597            "precondition: the floor is gone",
12598        );
12599
12600        follow_control(&bed.relay, &community).await.unwrap();
12601
12602        assert!(
12603            crate::db::community::get_edition_head(&cid_hex, &grant_eid).unwrap().is_some(),
12604            "the fold MUST page past the newest window and reach the grant that defines the \
12605             admin list — stopping at the first coherent-looking page is how a community \
12606             becomes unable to re-derive its own roster",
12607        );
12608        assert!(
12609            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12610            "and the admin survives",
12611        );
12612    }
12613
12614    #[tokio::test]
12615    async fn a_grant_naming_a_role_its_own_holder_created_still_folds() {
12616        // The deadlock that emptied two live communities' rosters.
12617        //
12618        // A community grows the ordinary way: the owner promotes an admin, the admin
12619        // creates a role of their own, and the owner then grants that role onward. Now
12620        // the grant references a role whose AUTHOR draws authority from a grant — and
12621        // under all-or-nothing resolution neither can go first. The fixpoint lands
12622        // empty, and every grant in the community dies with it, the owner's included.
12623        //
12624        // Resolving the roles a grant CAN resolve breaks the cycle without granting
12625        // anything the author did not write: the retained roles are still rank-checked,
12626        // and the rest fold in on a later iteration.
12627        let (bed, owner, _m) = TestBed::new();
12628        bed.swap_to(&owner);
12629        let community = create_community(&bed.relay, "GrantCycle", bed.relays.clone(), None).await.unwrap();
12630        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12631        let admin = Keys::generate();
12632        let member = Keys::generate();
12633
12634        // 1. The owner's own role, and a grant of it to the admin.
12635        let owner_role = "aa".repeat(32);
12636        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&owner_role, Permissions::ADMIN_ALL), 1).await;
12637        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![owner_role.clone()], 1).await;
12638        follow_control(&bed.relay, &community).await.unwrap();
12639        assert!(
12640            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12641            "the admin is promoted",
12642        );
12643
12644        // 2. The ADMIN creates a role of their own, beneath theirs.
12645        let admin_made = "bb".repeat(32);
12646        let mut made = admin_role(&admin_made, Permissions::BAN);
12647        made.position = 5;
12648        publish_role(&bed.relay, &community, &admin, &made, 1).await;
12649
12650        follow_control(&bed.relay, &community).await.unwrap();
12651        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12652        assert!(
12653            roster.role(&admin_made).is_some(),
12654            "an admin-authored role folds while its author's grant is resolvable",
12655        );
12656        assert!(
12657            roster.is_admin(&admin.public_key().to_hex()),
12658            "and the admin keeps their own standing — a cycle here empties the roster",
12659        );
12660
12661        // 3. The owner grants the ADMIN-MADE role to a member, alongside a role that
12662        //    is not resolvable at all. Under all-or-nothing the whole grant dies and
12663        //    takes the roster with it; resolved partially, the member gets what the
12664        //    owner actually granted.
12665        publish_grant(
12666            &bed.relay, &community, &owner.keys, &member.public_key(),
12667            vec![admin_made.clone(), "cc".repeat(32)], 1,
12668        ).await;
12669        follow_control(&bed.relay, &community).await.unwrap();
12670        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12671        let mg = roster.grants.iter().find(|g| g.member == member.public_key().to_hex());
12672        assert!(
12673            mg.is_some_and(|g| g.role_ids.contains(&admin_made)),
12674            "the resolvable half of the grant is honored",
12675        );
12676        assert!(
12677            mg.is_some_and(|g| !g.role_ids.iter().any(|r| r == &"cc".repeat(32))),
12678            "and the unresolvable half is NOT — partial resolution only ever grants less",
12679        );
12680        assert!(
12681            roster.is_admin(&admin.public_key().to_hex()),
12682            "the rest of the roster survives it",
12683        );
12684    }
12685
12686    #[tokio::test]
12687    async fn a_cold_device_pages_past_the_first_window_to_find_an_older_grant() {
12688        // The early break stops paging once every entity we track has a head. On a
12689        // COLD device we track NOTHING, so that condition is vacuously true and the
12690        // walk stops at page one — the roster is then whatever the newest window
12691        // happened to hold, and an admin granted before it silently is not one.
12692        // That is the roster collapse, arrived at by an optimisation.
12693        let (bed, owner, _m) = TestBed::new();
12694        bed.swap_to(&owner);
12695        let community = create_community(&bed.relay, "ColdPage", bed.relays.clone(), None).await.unwrap();
12696        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12697
12698        // The grant that matters goes down FIRST, so a full newest-first page buries it.
12699        let admin = Keys::generate();
12700        let rid = "e5".repeat(32);
12701        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12702        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12703
12704        // Then bury it under a full page of newer editions.
12705        for v in 2..(FOLLOW_PAGE as u64 + 2) {
12706            publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000 + v).await;
12707        }
12708
12709        follow_control(&bed.relay, &community).await.unwrap();
12710
12711        assert!(
12712            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12713            "a cold device must page PAST the first window — stopping there caches a \
12714             partial authority as its own baseline and drops every older grant",
12715        );
12716    }
12717
12718    #[tokio::test]
12719    async fn a_truncated_cold_fold_banks_nothing_at_all() {
12720        // A page that is entirely one second wide is a wall `until` cannot step past,
12721        // so the plane below it is unreachable and the fold is TRUNCATED. Refusing to
12722        // show that partial roster is not enough — it must not be BANKED either. A
12723        // stored baseline carries a provenance stamp that the next, complete fold has
12724        // to beat, and names entities that a complete fold may not head, so banking a
12725        // partial window is how a client talks itself into a permanent wedge.
12726        let (bed, owner, _m) = TestBed::new();
12727        bed.swap_to(&owner);
12728        let community = create_community(&bed.relay, "Wall", bed.relays.clone(), None).await.unwrap();
12729        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12730
12731        let admin = Keys::generate();
12732        let rid = "e5".repeat(32);
12733        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12734        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12735        // A full page of editions sharing ONE second, burying the grant behind the wall.
12736        for v in 2..(FOLLOW_PAGE as u64 + 2) {
12737            publish_filler_edition(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), v, 1_000).await;
12738        }
12739
12740        follow_control(&bed.relay, &community).await.unwrap();
12741
12742        assert_eq!(
12743            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12744            0,
12745            "a truncated fold must bank NO baseline — a stored partial roster outranks \
12746             the complete fold that follows it and can never be replaced",
12747        );
12748    }
12749
12750    #[tokio::test]
12751    async fn an_older_window_never_overwrites_a_newer_cached_roster() {
12752        // The roster cache carries the provenance timestamp of the fold that wrote it.
12753        // A later fold reading an OLDER window (a lagging relay, a short page) must not
12754        // replace it — that is a downgrade with no gap and no truncation to catch it.
12755        let (bed, owner, _m) = TestBed::new();
12756        bed.swap_to(&owner);
12757        let community = create_community(&bed.relay, "Monotone", bed.relays.clone(), None).await.unwrap();
12758        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12759
12760        let admin = Keys::generate();
12761        let rid = "e5".repeat(32);
12762        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12763        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12764        follow_control(&bed.relay, &community).await.unwrap();
12765        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12766
12767        // Stamp the cached roster far in the future: any real fold now reads older.
12768        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12769        crate::db::community::set_community_roles(&cid_hex, &roster, i64::MAX).unwrap();
12770
12771        follow_control(&bed.relay, &community).await.unwrap();
12772        assert!(
12773            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12774            "an older window must not overwrite a newer cached roster",
12775        );
12776        assert_eq!(
12777            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
12778            i64::MAX,
12779            "and the newer provenance stamp survives — otherwise the next fold downgrades freely",
12780        );
12781    }
12782
12783    /// A fast majority that does not hold the older editions. Quorum returns as soon
12784    /// as it answers, so those editions read as absent; only a Full read waits for the
12785    /// relay that has them. This is the shape a slow Ditto/Soapbox relay produces.
12786    struct SlowMajority<'a>(&'a MemoryRelay, u64);
12787    #[async_trait::async_trait]
12788    impl Transport for SlowMajority<'_> {
12789        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
12790            self.0.publish(e, r).await
12791        }
12792        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
12793            let out = self.0.fetch(query, relays).await?;
12794            if query.evidence == crate::community::transport::Evidence::Full {
12795                return Ok(out);
12796            }
12797            Ok(out.into_iter().filter(|e| e.created_at.as_secs() > self.1).collect())
12798        }
12799        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
12800            self.0.fetch_plane(plane, query, relays).await
12801        }
12802        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
12803            self.0.publish_durable(e, r).await
12804        }
12805    }
12806
12807    #[tokio::test]
12808    async fn a_live_link_a_quorum_cannot_see_still_reads_public() {
12809        // The ban path asks this to decide whether to refound. CORD-05 §5 forbids
12810        // refounding a Public community — the link refresh re-posts the bundle behind
12811        // the same URL, so the rotation severs nothing and strands foreign-link joiners
12812        // on a buried epoch. A Quorum read that misses another creator's Registry makes
12813        // a Public community read Private, which takes exactly that forbidden branch.
12814        let (_tmp, _guard, _owner) = init_test_db();
12815        let relay = MemoryRelay::new();
12816        let community = create_community(&relay, "QuorumBlind", vec!["wss://r".into()], None).await.unwrap();
12817        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
12818        assert!(community_is_public(&relay, &community).await, "the link is live on the plane");
12819
12820        // u64::MAX: nothing is newer, so the Quorum read is blinded entirely. The
12821        // link is minted with the real clock, so a small cutoff filters nothing.
12822        let slow = SlowMajority(&relay, u64::MAX);
12823        assert!(
12824            community_is_public(&slow, &community).await,
12825            "a live link only a minority relay serves must still read Public — reading \
12826             Private here refounds a Public community on ban, severing nothing",
12827        );
12828    }
12829
12830    #[tokio::test]
12831    async fn a_quorum_that_cannot_see_a_stored_entity_escalates_to_full() {
12832        // The roster fold draws an absence verdict, and an absence verdict from a
12833        // MAJORITY is not one: the same fast subset answers every boot, so the retain
12834        // never lifts and the cache freezes holding the only surviving copy of the
12835        // roster. Read again at Full before concluding anything is gone.
12836        let (bed, owner, _m) = TestBed::new();
12837        bed.swap_to(&owner);
12838        let community = create_community(&bed.relay, "Escalate", bed.relays.clone(), None).await.unwrap();
12839        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12840
12841        let admin = Keys::generate();
12842        let rid = "e5".repeat(32);
12843        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12844        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid.clone()], 1).await;
12845
12846        // Bank a complete roster the honest way, so the entities are STORED and floored.
12847        follow_control(&bed.relay, &community).await.unwrap();
12848        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()));
12849
12850        // A SECOND admin is granted. Its edition lands on the plane like every other,
12851        // below the cutoff — so a Quorum read cannot see it, and a client that concludes
12852        // from Quorum alone retains the old roster and never learns about them.
12853        let admin2 = Keys::generate();
12854        publish_grant(&bed.relay, &community, &owner.keys, &admin2.public_key(), vec![rid], 1).await;
12855
12856        let slow = SlowMajority(&bed.relay, 5_000);
12857        follow_control(&slow, &community).await.unwrap();
12858
12859        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12860        assert!(
12861            roster.is_admin(&admin.public_key().to_hex()),
12862            "the first admin survives — the retain is doing its job",
12863        );
12864        assert!(
12865            roster.is_admin(&admin2.public_key().to_hex()),
12866            "and the roster still ADVANCES: a Quorum read that could not account for a \
12867             stored entity must re-read at Full, or the cache freezes holding the only \
12868             surviving copy of the roster and no promotion ever lands again",
12869        );
12870    }
12871
12872    #[tokio::test]
12873    async fn one_gapped_entity_does_not_freeze_the_rest_of_the_roster() {
12874        // `authority.gapped` is a global OR across every entity, but the spec makes a
12875        // gap fail closed FOR THAT ENTITY — suspend it, refetch it — not for the whole
12876        // community. Treating it as a veto froze live rosters that were otherwise
12877        // folding perfectly, and permanently once the plane is exhausted and no further
12878        // paging can resolve the hole.
12879        let (bed, owner, _m) = TestBed::new();
12880        bed.swap_to(&owner);
12881        let community = create_community(&bed.relay, "OneGap", bed.relays.clone(), None).await.unwrap();
12882        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12883
12884        // A healthy admin the plane fully describes.
12885        let admin = Keys::generate();
12886        let rid = "e5".repeat(32);
12887        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
12888        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
12889
12890        // And a second role the plane DOES serve, floored at a hash nothing chains to:
12891        // its editions are present but unanchored, which is the withholding shape that
12892        // fails closed and raises the gap. A floor on an entity absent from the window
12893        // would not — the fold only visits entities it sees.
12894        let rid2 = "c7".repeat(32);
12895        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid2, Permissions::BAN), 5).await;
12896        crate::db::community::set_edition_head_at_epoch(&cid_hex, &rid2, 3, &[9u8; 32], &[9u8; 32], community.root_epoch.0).unwrap();
12897
12898        follow_control(&bed.relay, &community).await.unwrap();
12899
12900        assert!(
12901            crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&admin.public_key().to_hex()),
12902            "a hole in ONE entity must not withhold every other entity's authority — that is \
12903             how a community that folds correctly still shows an empty roster",
12904        );
12905    }
12906
12907    #[tokio::test]
12908    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
12909        let (bed, owner, _m) = TestBed::new();
12910        bed.swap_to(&owner);
12911        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
12912        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12913        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
12914        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
12915
12916        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
12917        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12918        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12919        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
12920        follow_control(&bed.relay, &community).await.unwrap().updated;
12921        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
12922
12923        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
12924        // drops both admins yet raises no gap. The completeness gate must RETAIN the
12925        // stored roster rather than persist the lossy one.
12926        let relay2 = MemoryRelay::new();
12927        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
12928        follow_control(&relay2, &community).await.unwrap().updated;
12929        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12930        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
12931    }
12932
12933    #[tokio::test]
12934    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
12935        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
12936        // Vector already gated roles and grants in-fold; metadata, channels and
12937        // the banlist resolved on permission alone, so a client one sweep behind
12938        // honored an edit from an admin whose demotion it had not read yet.
12939        let (_tmp, _guard, owner) = init_test_db();
12940        let relay = MemoryRelay::new();
12941        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12942        let admin = Keys::generate();
12943        let rid = "a7".repeat(32);
12944        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
12945        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
12946
12947        // The admin acts WITHOUT citing (what every pre-citation client emitted).
12948        let group = control::ControlPlane::of(&community).write_group().unwrap();
12949        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
12950        let rumor = control::build_edition_rumor(
12951            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
12952            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
12953            &serde_json::to_string(&meta).unwrap(), 1_000, None,
12954        );
12955        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12956        relay.publish(&wrap, &community.relays).await.unwrap();
12957
12958        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
12959        let victim = Keys::generate().public_key().to_hex();
12960        let ban_rumor = control::build_edition_rumor(
12961            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
12962            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
12963        );
12964        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
12965        relay.publish(&ban_wrap, &community.relays).await.unwrap();
12966
12967        let updated = follow_control(&relay, &community).await.unwrap().updated;
12968        assert!(
12969            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
12970            "an uncited metadata edit must not be honored",
12971        );
12972        let authority = fetch_authority(&relay, &community).await;
12973        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
12974        // The positive case (this same admin, citing, lands) is
12975        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
12976        // helper cites, so it proves the gate is the CITATION and not the
12977        // permission. Re-proving it here would need a fresh chain anyway: a
12978        // cited edition chaining onto the rejected one above is gapped, not
12979        // refused.
12980    }
12981
12982    #[tokio::test]
12983    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
12984        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
12985        // owner revokes the grant, the (now unauthorized) admin's further edit drops
12986        // and the name holds at the last authorized state.
12987        let (_tmp, _guard, owner) = init_test_db();
12988        let relay = MemoryRelay::new();
12989        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
12990        let admin = Keys::generate();
12991        let rid = "a1".repeat(32);
12992        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
12993        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
12994        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
12995
12996        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("admin edit authorized");
12997        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
12998
12999        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
13000        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
13001        let _ = follow_control(&relay, &community).await.unwrap().updated;
13002        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13003        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
13004    }
13005
13006    #[tokio::test]
13007    async fn a_roleless_member_cannot_edit_metadata() {
13008        let (_tmp, _guard, _owner) = init_test_db();
13009        let relay = MemoryRelay::new();
13010        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
13011        let stranger = Keys::generate();
13012        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
13013        assert!(
13014            follow_control(&relay, &community).await.unwrap().updated.is_none(),
13015            "a roleless member's metadata edit never folds"
13016        );
13017    }
13018
13019    #[tokio::test]
13020    async fn a_self_signed_grant_is_not_authority() {
13021        // The self-promotion defense: a member self-signs both a role and a grant of
13022        // it to themselves. authorize_delegation drops both (their signer never traces
13023        // to the owner), so their metadata edit stays unauthorized.
13024        let (_tmp, _guard, _owner) = init_test_db();
13025        let relay = MemoryRelay::new();
13026        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
13027        let rogue = Keys::generate();
13028        let rid = "b2".repeat(32);
13029        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
13030        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
13031        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
13032        assert!(
13033            follow_control(&relay, &community).await.unwrap().updated.is_none(),
13034            "a self-signed grant confers no authority"
13035        );
13036    }
13037
13038    #[tokio::test]
13039    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
13040        let (_tmp, _guard, owner) = init_test_db();
13041        let relay = MemoryRelay::new();
13042        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
13043        let target = "cc".repeat(32);
13044
13045        // A non-BAN-holder's banlist edition is folded but NOT enforced.
13046        let rogue = Keys::generate();
13047        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
13048        let floors = load_floors(&community);
13049        let editions = fetch_control(&relay, &community).await;
13050        let authority = fold_authority(&community, &editions, &floors);
13051        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
13052
13053        // The owner (supreme, holds BAN) bans the target: now enforced.
13054        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
13055        let editions = fetch_control(&relay, &community).await;
13056        let authority = fold_authority(&community, &editions, &floors);
13057        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
13058    }
13059
13060    #[tokio::test]
13061    async fn a_banned_admin_loses_all_authority() {
13062        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
13063        // banned admin's authority is dropped and their edits refused.
13064        let (_tmp, _guard, owner) = init_test_db();
13065        let relay = MemoryRelay::new();
13066        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
13067        let admin = Keys::generate();
13068        let rid = "e5".repeat(32);
13069        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
13070        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
13071        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
13072        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
13073
13074        assert!(
13075            follow_control(&relay, &community).await.unwrap().updated.is_none(),
13076            "a banned admin's edit is dropped even with an unstripped grant"
13077        );
13078        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13079        assert!(authority.banned.contains(&admin.public_key().to_hex()));
13080        assert!(
13081            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
13082            "a banned admin holds no bit"
13083        );
13084    }
13085
13086    #[tokio::test]
13087    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
13088        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
13089        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
13090        // and NOT the owner (supreme, unbannable).
13091        let (_tmp, _guard, owner) = init_test_db();
13092        let relay = MemoryRelay::new();
13093        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
13094        let admin = Keys::generate();
13095        let moder = Keys::generate();
13096        let stranger = Keys::generate();
13097        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
13098        publish_role(&relay, &community, &owner, &Role { role_id: admin_rid.clone(), name: "Admin".into(), position: 1, permissions: Permissions(Permissions::ADMIN_ALL), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
13099        publish_role(&relay, &community, &owner, &Role { role_id: mod_rid.clone(), name: "Mod".into(), position: 2, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
13100        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
13101        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
13102        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
13103
13104        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13105        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
13106        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
13107        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
13108    }
13109
13110    #[tokio::test]
13111    async fn an_unauthorized_higher_banlist_cannot_unban() {
13112        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
13113        // must not erase the owner's ban (author-aware head selection + persisted
13114        // banlist retention).
13115        let (_tmp, _guard, owner) = init_test_db();
13116        let relay = MemoryRelay::new();
13117        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
13118        let target = "cc".repeat(32);
13119        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
13120        follow_control(&relay, &community).await.unwrap().updated; // persists the ban
13121
13122        let rogue = Keys::generate();
13123        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
13124        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
13125        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
13126    }
13127
13128    #[tokio::test]
13129    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
13130        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
13131        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
13132        let (_tmp, _guard, _owner) = init_test_db();
13133        let relay = MemoryRelay::new();
13134        let relays = vec!["wss://r".to_string()];
13135        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
13136        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
13137        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
13138
13139        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
13140        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
13141        assert_eq!(rehydrated[0].id().0, community.id().0);
13142        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
13143    }
13144
13145    #[tokio::test]
13146    async fn a_retried_leave_tombstone_cannot_bury_a_rejoin_made_meanwhile() {
13147        let (_tmp, _guard, _owner) = init_test_db();
13148        let relay = MemoryRelay::new();
13149        let relays = vec!["wss://r".to_string()];
13150        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
13151        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13152
13153        // The leave stamps its own removal time, and its publish fails.
13154        let left_at = now_ms() - 1000;
13155        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
13156        // The user rejoins before the retry gets through.
13157        republish_community_list(&relay, Some(community.id())).await.unwrap();
13158        assert!(fetch_fragments(&relay, &relays).await.unwrap().unwrap().list.is_live(&cid));
13159
13160        // The retry lands late, carrying the ORIGINAL stamp — which the rejoin outranks.
13161        tombstone_community_list(&relay, community.id(), &relays, left_at).await.unwrap();
13162        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
13163        assert!(set.list.is_live(&cid), "a retry re-stamping `now` would have buried the rejoin");
13164    }
13165
13166    #[tokio::test]
13167    async fn a_boot_with_no_fragments_seeds_the_list_from_local_state() {
13168        let (_tmp, _guard, _owner) = init_test_db();
13169        let relay = MemoryRelay::new();
13170        let relays = vec!["wss://r".to_string()];
13171        let community = create_community(&relay, "Seeded", relays.clone(), None).await.unwrap();
13172
13173        // An account arriving from the retired single-event list: held locally, nothing
13174        // at the fragment coordinate. Boot alone has to write it — no membership changes.
13175        let bare = MemoryRelay::new();
13176        sync_community_list(&bare, &relays).await.unwrap();
13177        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("boot seeded the List");
13178        let live = set.list.live_entries();
13179        assert_eq!(live.len(), 1);
13180        assert_eq!(live[0].community_id, crate::simd::hex::bytes_to_hex_32(&community.id().0));
13181
13182        // Latched: a LATER empty read is a relay that lost the fragments, not a fresh
13183        // account, and republishing local state there would bury a sibling's tombstones.
13184        let empty = MemoryRelay::new();
13185        sync_community_list(&empty, &relays).await.unwrap();
13186        assert_eq!(empty.stored_count(), 0, "the seed runs once");
13187    }
13188
13189    #[tokio::test]
13190    async fn a_dissolved_invite_retires_on_accept_and_tombstones_the_list() {
13191        // The invite to a dissolved community is the one join failure with no
13192        // retry: the refusal is an owner-signed grave. The boundary must retire
13193        // the parked row AND write the §8 tombstone siblings purge on.
13194        let (bed, owner, member) = TestBed::new();
13195        bed.swap_to(&owner);
13196        let community = create_community(&bed.relay, "Grave", bed.relays.clone(), None).await.unwrap();
13197        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13198        dissolve_community(&bed.relay, &community).await.unwrap();
13199
13200        bed.swap_to(&member);
13201        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13202        crate::db::community::save_pending_invite(&cid_hex, &bundle, "inviter", i64::MAX).unwrap();
13203        let err = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap_err();
13204        assert_eq!(err, ERR_DISSOLVED, "the grave refusal is the sentinel the boundary matches");
13205
13206        retire_dead_invite(&bed.relay, &cid_hex, &bed.relays).await;
13207        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the dead invite leaves this device");
13208        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the tombstone published");
13209        assert!(set.list.tombstones.iter().any(|t| t.community_id == cid_hex), "and siblings get the §8 tombstone");
13210        assert!(!set.list.is_live(&cid_hex));
13211    }
13212
13213    #[tokio::test]
13214    async fn an_owner_dissolve_tombstones_the_list_for_sibling_devices() {
13215        // Dissolving IS leaving. The grave alone reaches every MEMBER, but only a §8
13216        // tombstone reaches the owner's own sibling devices — without it they keep the
13217        // membership LIVE in the List, fold the grave, and show a sealed husk the owner
13218        // already deleted, on every device, until each is cleaned up by hand.
13219        let (bed, owner, _member) = TestBed::new();
13220        bed.swap_to(&owner);
13221        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13222        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13223        let before = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("create published the List");
13224        assert!(before.list.is_live(&cid_hex), "the membership is live before the dissolve");
13225
13226        dissolve_community(&bed.relay, &community).await.unwrap();
13227
13228        let set = fetch_fragments(&bed.relay, &bed.relays).await.unwrap().expect("the List outlives the dissolve");
13229        assert!(
13230            set.list.tombstones.iter().any(|t| t.community_id == cid_hex),
13231            "the dissolve published the §8 tombstone alongside the grave"
13232        );
13233        assert!(!set.list.is_live(&cid_hex), "sibling devices tear the husk down on their next list sync");
13234    }
13235
13236    #[tokio::test]
13237    async fn a_sibling_devices_list_tombstone_purges_the_parked_invite() {
13238        // Device A declined (or retired a dead invite): its §8 tombstone must
13239        // clear device B's parked copy on the next list sync — and ONLY when the
13240        // removal post-dates the invite, so a fresh re-invite survives an old leave.
13241        let (_tmp, _guard, _owner) = init_test_db();
13242        let relay = MemoryRelay::new();
13243        let relays = vec!["wss://r".to_string()];
13244        let cid = crate::community::CommunityId([0x5A; 32]);
13245        let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
13246
13247        // An OLD tombstone (a past leave) must not kill an invite received after it.
13248        tombstone_community_list(&relay, &cid, &relays, now_ms() - 600_000).await.unwrap();
13249        crate::db::community::save_pending_invite(&cid_hex, "{}", "inviter", i64::MAX).unwrap();
13250        sync_community_list(&relay, &relays).await.unwrap();
13251        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_some(), "a re-invite supersedes a past leave");
13252
13253        // A tombstone NEWER than the invite is a verdict on it: purged on sync.
13254        tombstone_community_list(&relay, &cid, &relays, now_ms() + 10_000).await.unwrap();
13255        sync_community_list(&relay, &relays).await.unwrap();
13256        assert!(crate::db::community::get_pending_invite(&cid_hex).unwrap().is_none(), "the sibling's removal clears the parked copy");
13257    }
13258
13259    #[tokio::test]
13260    async fn an_unchanged_republish_rewrites_no_fragment() {
13261        // Nothing changed since the last write: the rebuilt fragment serializes to
13262        // the bytes already on the relay, so the write must not touch the wire —
13263        // a bumped created_at on identical content is pure churn.
13264        let (_tmp, _guard, _owner) = init_test_db();
13265        let relay = MemoryRelay::new();
13266        let relays = vec!["wss://r".to_string()];
13267        create_community(&relay, "Stable", relays.clone(), None).await.unwrap();
13268        let before = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
13269
13270        republish_community_list(&relay, None).await.unwrap();
13271        let after = fetch_fragments(&relay, &relays).await.unwrap().unwrap().created_at;
13272        assert_eq!(before, after, "a byte-identical fragment must not republish");
13273    }
13274
13275    #[tokio::test]
13276    async fn a_frags_tie_at_equal_age_resolves_to_the_larger_count() {
13277        // A torn repack's worst case: two fragments at the SAME created_at
13278        // disagreeing on the total. The larger count must govern (CORD-02 §8) —
13279        // the smaller would push index 2 out of range and its memberships dormant.
13280        let (_tmp, _guard, owner) = init_test_db();
13281        let relay = MemoryRelay::new();
13282        let relays = vec!["wss://r".to_string()];
13283        let at = now_ms() / 1000;
13284        let two = super::super::list_frag::FragList { frags: 2, entries: vec![], tombstones: vec![], extra: Default::default() };
13285        let three = super::super::list_frag::FragList { frags: 3, entries: vec![], tombstones: vec![], extra: Default::default() };
13286        // The larger count sits at the LOWER index: an age-only rule that keeps
13287        // the last fragment seen would pick 2 here, not 3.
13288        for (frag, index) in [(&three, 0usize), (&two, 1usize)] {
13289            let e = super::super::list_frag::build_fragment_event_keys(&owner, frag, index, at).unwrap();
13290            relay.publish(&e, &relays).await.unwrap();
13291        }
13292        let set = fetch_fragments(&relay, &relays).await.unwrap().unwrap();
13293        assert_eq!(set.declared, 3, "an age tie resolves to the larger frags");
13294        assert!(!set.is_complete(), "index 2 is unread, so the set refuses to write");
13295    }
13296
13297    /// Aggregate fetches answer from the healthy relay; asked alone, the dead
13298    /// relay errors — the partial-failure shape a boot read can't see through.
13299    struct HalfDeaf<'a>(&'a MemoryRelay, String);
13300    #[async_trait::async_trait]
13301    impl Transport for HalfDeaf<'_> {
13302        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
13303            self.0.publish(e, r).await
13304        }
13305        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
13306            if relays.len() == 1 && relays[0] == self.1 {
13307                return Err("relay did not answer the fetch".to_string());
13308            }
13309            self.0.fetch(query, relays).await
13310        }
13311        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
13312            self.0.fetch_plane(plane, query, relays).await
13313        }
13314        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
13315            self.0.publish_durable(e, r).await
13316        }
13317    }
13318
13319    #[tokio::test]
13320    async fn an_unconfirmed_empty_read_never_seeds() {
13321        // One of the account's relays never answers. The aggregate read comes back
13322        // empty (the healthy relay has nothing), which is indistinguishable from a
13323        // fresh account — but the dead relay may hold a sibling's tombstones, so
13324        // seeding here would bury them. The seed must defer, unlatched, and land
13325        // once every relay confirms empty.
13326        let (_tmp, _guard, _owner) = init_test_db();
13327        let relay = MemoryRelay::new();
13328        let relays = vec!["wss://r".to_string(), "wss://dead".to_string()];
13329        let community = create_community(&relay, "Gated", relays.clone(), None).await.unwrap();
13330        let cid = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13331
13332        let bare = MemoryRelay::new();
13333        let half_deaf = HalfDeaf(&bare, "wss://dead".to_string());
13334        sync_community_list(&half_deaf, &relays).await.unwrap();
13335        assert_eq!(bare.stored_count(), 0, "a failed read must never seed");
13336        assert!(
13337            crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_none(),
13338            "a deferred seed must not latch"
13339        );
13340
13341        // The relay comes back: every relay now confirms empty, and the seed lands.
13342        sync_community_list(&bare, &relays).await.unwrap();
13343        let set = fetch_fragments(&bare, &relays).await.unwrap().expect("the confirmed-empty boot seeds");
13344        assert!(set.list.is_live(&cid));
13345        assert!(crate::db::settings::get_sql_setting(LIST_SEEDED_KEY.to_string()).unwrap().is_some());
13346    }
13347
13348    #[tokio::test]
13349    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
13350        let (_tmp, _guard, _owner) = init_test_db();
13351        let relay = MemoryRelay::new();
13352        let relays = vec!["wss://r".to_string()];
13353        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
13354        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
13355
13356        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
13357        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
13358    }
13359
13360    #[tokio::test]
13361    async fn accepting_the_same_bundle_twice_is_idempotent() {
13362        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
13363        // again must upsert cleanly — same community_id, no duplicate channels, no
13364        // corruption, the keys unchanged.
13365        let (bed, owner, member) = TestBed::new();
13366        bed.swap_to(&owner);
13367        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
13368        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
13369        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13370        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13371
13372        bed.swap_to(&member);
13373        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
13374        let channels_after_first = first.channels.len();
13375        let root_after_first = first.community_root;
13376
13377        // Accept the identical bundle again (restart / redelivery).
13378        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
13379        assert_eq!(second.id().0, first.id().0, "same community_id");
13380        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
13381        assert_eq!(second.community_root, root_after_first, "root unchanged");
13382
13383        // The persisted state is a single clean community with the expected channels.
13384        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13385        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
13386        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
13387    }
13388
13389    #[tokio::test]
13390    async fn a_fresh_joiner_can_authorize_a_moderators_hide_before_any_follow() {
13391        // The join walk reads the control plane to genesis anyway, and it already
13392        // folds the full roster for the ban gate. Throwing that roster away left
13393        // the fresh member's stored roles EMPTY until the follow worker's queued
13394        // re-walk — and the hide gate reads the stored roster, so every message a
13395        // moderator had hidden rendered to exactly the person who should never
13396        // see it: someone who just walked in.
13397        let (bed, owner, member) = TestBed::new();
13398        let admin = Keys::generate();
13399        bed.swap_to(&owner);
13400        let community = create_community(&bed.relay, "Moderated", bed.relays.clone(), None).await.unwrap();
13401        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
13402        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13403
13404        // A FRESH account accepts. No follow_control runs in this test — what the
13405        // join itself persisted is all the joiner has.
13406        bed.swap_to(&member);
13407        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13408        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
13409        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
13410
13411        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
13412        assert!(!roster.grants.is_empty(), "the join persisted the roster it already folded");
13413        let owner_hex = joined.owner().unwrap().to_hex();
13414        let victim = Keys::generate().public_key().to_hex();
13415        assert!(
13416            crate::community::moderation::can_hide(Some(&owner_hex), &roster, &admin.public_key().to_hex(), &victim),
13417            "the admin's hides are honorable on the FIRST history ingest, not after a follow"
13418        );
13419        // And the grant's refuse-downgrade floor is seeded, so the citation gate
13420        // (`actor_authority_pinned`) can resolve the same grant.
13421        let grant_hex = crate::simd::hex::bytes_to_hex_32(&crate::community::v2::derive::grant_locator(
13422            joined.id(),
13423            &admin.public_key().to_bytes(),
13424        ));
13425        assert!(
13426            crate::db::community::get_edition_head(&cid_hex, &grant_hex).unwrap().is_some(),
13427            "the admin-signed grant head survived the join"
13428        );
13429    }
13430
13431    #[tokio::test]
13432    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
13433        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
13434        // severs a member; the owner then unbans + sends a FRESH invite carrying the
13435        // NEW root; the member rejoins at the new epoch and converses again. Proves
13436        // a ban is reversible end-to-end, not a one-way door.
13437        let (bed, owner, member) = TestBed::new();
13438        bed.swap_to(&owner);
13439        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
13440        let general = community.channels[0].id;
13441        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
13442        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
13443
13444        bed.swap_to(&member);
13445        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
13446        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
13447        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
13448
13449        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
13450        bed.swap_to(&owner);
13451        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
13452        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
13453        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
13454        assert_eq!(community.root_epoch, Epoch(1));
13455        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
13456
13457        // The member's follow concludes severance (no blob at the new epoch).
13458        bed.swap_to(&member);
13459        let session = crate::db::current_session();
13460        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
13461
13462        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
13463        // directly, so the test picks the NEW invite unambiguously rather than an
13464        // arbitrary one of the two pending 3313s).
13465        bed.swap_to(&owner);
13466        set_banlist(&bed.relay, &community, &[]).await.unwrap();
13467        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13468        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
13469        let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
13470
13471        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
13472        bed.swap_to(&member);
13473        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
13474        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
13475        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
13476        let seen = texts_in(&bed.relay, &rejoined, &general).await;
13477        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
13478        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
13479
13480        bed.swap_to(&owner);
13481        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13482        assert!(
13483            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
13484            "the re-admitted member converses again at the new epoch"
13485        );
13486        // And they're back in the memberlist.
13487        let members = memberlist(&bed.relay, &community).await.unwrap();
13488        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
13489    }
13490
13491    #[tokio::test]
13492    async fn dissolution_blocks_a_join() {
13493        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
13494        // refuses to join.
13495        let (bed, owner, member) = TestBed::new();
13496        bed.swap_to(&owner);
13497        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13498        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13499        let bundle_json = serde_json::to_string(&bundle).unwrap();
13500        dissolve_community(&bed.relay, &community).await.unwrap();
13501        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
13502
13503        bed.swap_to(&member);
13504        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
13505        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
13506    }
13507
13508    #[tokio::test]
13509    async fn dissolution_seals_writes_but_not_reads() {
13510        // CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
13511        // the history stays browsable, and only explicit user intent deletes it.
13512        let (bed, owner, _member) = TestBed::new();
13513        bed.swap_to(&owner);
13514        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
13515        let general = community.channels[0].id;
13516        send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
13517
13518        dissolve_community(&bed.relay, &community).await.unwrap();
13519        let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13520
13521        for err in [
13522            send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
13523            send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
13524                .await
13525                .unwrap_err(),
13526            send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised", &[]).await.unwrap_err(),
13527        ] {
13528            assert!(err.contains("dissolved"), "every write is refused, got: {err}");
13529        }
13530        assert!(
13531            texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
13532            "but the history still reads"
13533        );
13534    }
13535
13536    #[tokio::test]
13537    async fn only_the_owner_can_dissolve() {
13538        let (bed, owner, member) = TestBed::new();
13539        bed.swap_to(&owner);
13540        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
13541        bed.swap_to(&member);
13542        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
13543        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
13544    }
13545
13546    #[tokio::test]
13547    async fn a_foreign_tombstone_is_not_death() {
13548        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
13549        // owner-gated), so the community is not treated as dead.
13550        let (_tmp, _guard, _owner) = init_test_db();
13551        let relay = MemoryRelay::new();
13552        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
13553        let rogue = Keys::generate();
13554        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
13555        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
13556        relay.publish(&wrap, &community.relays).await.unwrap();
13557        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
13558    }
13559
13560    #[tokio::test]
13561    async fn a_public_channel_reads_history_across_a_refounding() {
13562        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
13563        // pre-rotation messages stay readable (the prior epoch's root is archived and
13564        // the read fans out across held epochs).
13565        let (_tmp, _guard, _owner) = init_test_db();
13566        let relay = MemoryRelay::new();
13567        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
13568        let general = community.channels[0].id;
13569        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
13570
13571        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
13572        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
13573        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
13574
13575        let open = texts_in(&relay, &refounded, &general).await;
13576        assert!(open.contains(&"after the refounding".to_string()), "opening reads the live epoch");
13577        assert!(
13578            !open.contains(&"before the refounding".to_string()),
13579            "and NOT the rotated-past one — that plane is the back-paging cursor's job"
13580        );
13581        let paged = all_texts_in(&relay, &refounded, &general).await;
13582        assert!(paged.contains(&"before the refounding".to_string()), "the epoch-0 message stays reachable by paging");
13583        assert!(paged.contains(&"after the refounding".to_string()), "alongside the epoch-1 one");
13584    }
13585
13586    #[tokio::test]
13587    async fn refounding_aborts_when_control_state_is_withheld() {
13588        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
13589        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
13590        // member at the new epoch a fresh joiner bootstraps).
13591        let (_tmp, _guard, owner) = init_test_db();
13592        let relay = MemoryRelay::new();
13593        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
13594        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
13595        follow_control(&relay, &community).await.unwrap().updated; // seed the banlist floor
13596
13597        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
13598        // reloads fresh state) fetches none of the committed heads.
13599        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13600        moved.relays = vec!["wss://empty".into()];
13601        crate::db::community::save_community_v2(&moved).unwrap();
13602
13603        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
13604        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
13605        assert_eq!(
13606            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
13607            Epoch(0),
13608            "the epoch did NOT advance (zero published state)"
13609        );
13610    }
13611
13612    #[tokio::test]
13613    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
13614        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
13615        // epoch advances, and the removed member's rekey-follow concludes they're cut.
13616        let (bed, owner, member) = TestBed::new();
13617        bed.swap_to(&owner);
13618        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
13619        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13620        let bundle_json = serde_json::to_string(&bundle).unwrap();
13621        bed.swap_to(&member);
13622        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13623
13624        bed.swap_to(&owner);
13625        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
13626        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
13627        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
13628        // The owner still reads the compacted control plane at the new epoch.
13629        assert_eq!(
13630            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
13631            Epoch(1),
13632            "the owner committed the new epoch"
13633        );
13634
13635        // The removed member, following rekeys, is severed (no blob in the rotation).
13636        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
13637        // swap now bumps the generation exactly like a production swap_session).
13638        bed.swap_to(&member);
13639        let session = crate::db::current_session();
13640        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
13641        assert!(follow.self_removed, "the removed member is cut by the re-founding");
13642    }
13643
13644    #[tokio::test]
13645    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
13646        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
13647        // non-owner admin granted BAN CAN re-found (and every member follows it —
13648        // see the receive-side test), but the "strictly outrank every removed
13649        // target" rule still holds: they can't use it to evict the owner.
13650        let (bed, owner, member) = TestBed::new();
13651        bed.swap_to(&owner);
13652        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
13653        let rid = "b0".repeat(32);
13654        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13655        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
13656        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13657        let bundle_json = serde_json::to_string(&bundle).unwrap();
13658        bed.swap_to(&member);
13659        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13660        // Fold the roster so this member's own DB reflects their BAN grant (the
13661        // authority check reads the folded Roster, not the bundle).
13662        let _ = follow_control(&bed.relay, &joined).await;
13663        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13664        // Can't evict the owner (no one outranks the owner).
13665        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
13666        // But CAN re-found removing a plain member they outrank (here, nobody).
13667        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
13668    }
13669
13670    #[tokio::test]
13671    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
13672        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
13673        // follow it — owner-only receive silently strands members whose community
13674        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
13675        // BAN", checked against the folded Roster).
13676        let (bed, owner, me) = TestBed::new();
13677        let admin = Keys::generate();
13678        bed.swap_to(&owner);
13679        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
13680        let rid = "b0".repeat(32);
13681        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13682        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13683
13684        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
13685        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13686        let bundle_json = serde_json::to_string(&bundle).unwrap();
13687        bed.swap_to(&me);
13688        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13689        let _ = follow_control(&bed.relay, &joined).await;
13690        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13691
13692        // The admin re-founds keeping the owner + me — the owner must always be a
13693        // recipient of a non-owner Refounding.
13694        let new_root = [0xC7; 32];
13695        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13696
13697        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13698            .expect("an authorized admin's Refounding is adopted");
13699        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
13700        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
13701    }
13702
13703    #[tokio::test]
13704    async fn adopting_a_severing_rotation_revokes_my_own_links_instead_of_refreshing_them() {
13705        // The inverse of the sibling test: a SEVERING refound flips the stable-URL
13706        // contract. A raid arrives through the door, so every creator's client
13707        // buries its own links on adopt rather than carrying them into the new
13708        // epoch — which is the only way the OWNER's link dies when a bot admin ran
13709        // the containment (only the creator holds a link's signer secret).
13710        let (bed, owner, me) = TestBed::new();
13711        bed.swap_to(&owner);
13712        let community = create_community(&bed.relay, "SeverHeal", bed.relays.clone(), None).await.unwrap();
13713        let rid = "b1".repeat(32);
13714        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13715        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13716
13717        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13718        let bundle_json = serde_json::to_string(&bundle).unwrap();
13719        bed.swap_to(&me);
13720        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13721        let _ = follow_control(&bed.relay, &joined).await;
13722        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13723
13724        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13725        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "my link is live before the raid");
13726
13727        // The owner runs a SEVERING refound (the raid lane).
13728        let new_root = [0xD5; 32];
13729        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13730
13731        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
13732            .expect("the owner's severing Refounding is adopted");
13733        assert_eq!(updated.root_epoch, Epoch(1), "I still advance to the new epoch");
13734
13735        let err = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err();
13736        assert!(err.contains("revoked"), "my link must be a grave, not a refreshed door: {err}");
13737        assert!(
13738            crate::db::community::list_public_invites(&crate::simd::hex::bytes_to_hex_32(&updated.id().0)).unwrap().is_empty(),
13739            "and the local record goes with it",
13740        );
13741    }
13742
13743    #[tokio::test(flavor = "multi_thread")]
13744    async fn a_severing_adopt_never_deadlocks_the_last_link_creator() {
13745        // The severance runs INSIDE the follow, which holds `follow_lock` for the
13746        // whole call. `revoke_public_link`'s last-live-link escalation re-founds —
13747        // taking that same non-reentrant lock — so routing severance through it
13748        // would wedge this community's follow forever, for the very client whose
13749        // revocation empties the registry (typically the owner). The escalation-free
13750        // path is the fix; this test is its guard.
13751        let (bed, owner, me) = TestBed::new();
13752        bed.swap_to(&owner);
13753        let community = create_community(&bed.relay, "NoDeadlock", bed.relays.clone(), None).await.unwrap();
13754        let rid = "b2".repeat(32);
13755        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13756        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13757        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13758        let bundle_json = serde_json::to_string(&bundle).unwrap();
13759        bed.swap_to(&me);
13760        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13761        let _ = follow_control(&bed.relay, &joined).await;
13762        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13763        // My link is the ONLY one in the registry — revoking it empties the
13764        // aggregate, which is exactly the escalation's trigger condition.
13765        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13766        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
13767
13768        let new_root = [0xD6; 32];
13769        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13770
13771        let followed = crate::rt::time::timeout(
13772            std::time::Duration::from_secs(20),
13773            follow_rekeys(&bed.relay, &joined, &crate::db::current_session()),
13774        )
13775        .await
13776        .expect("the severing follow must not deadlock on follow_lock")
13777        .unwrap();
13778        assert_eq!(followed.updated.expect("adopted").root_epoch, Epoch(1));
13779    }
13780
13781    #[tokio::test(flavor = "multi_thread")]
13782    async fn a_severing_adopt_spares_a_link_minted_after_the_raid() {
13783        // Severance is bounded by the rotation's own wire stamp, not "now". A
13784        // second device that adopts late must not eat the fresh link the owner
13785        // minted to re-open the community after the raid was over.
13786        let (bed, owner, me) = TestBed::new();
13787        bed.swap_to(&owner);
13788        let community = create_community(&bed.relay, "FreshDoor", bed.relays.clone(), None).await.unwrap();
13789        let rid = "b3".repeat(32);
13790        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
13791        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13792        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13793        let bundle_json = serde_json::to_string(&bundle).unwrap();
13794        bed.swap_to(&me);
13795        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13796        let _ = follow_control(&bed.relay, &joined).await;
13797        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13798        let raid_era = mint_public_link(&bed.relay, &joined, "https://old", None, None).await.unwrap();
13799
13800        let new_root = [0xD7; 32];
13801        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
13802        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("adopted");
13803        assert!(
13804            fetch_public_bundle(&bed.relay, &raid_era.url).await.unwrap_err().contains("revoked"),
13805            "the raid-era link is buried",
13806        );
13807
13808        // The raid is over and the owner deliberately re-opens the community. The
13809        // pending-severance retry runs on every follow, so a door minted after the
13810        // containment must survive them all — otherwise no one could ever re-open.
13811        let fresh = mint_public_link(&bed.relay, &updated, "https://new", None, None).await.unwrap();
13812        let _ = follow_rekeys(&bed.relay, &updated, &crate::db::current_session()).await.unwrap();
13813        assert!(
13814            fetch_public_bundle(&bed.relay, &fresh.url).await.is_ok(),
13815            "a link minted after the containment survives later follow passes",
13816        );
13817    }
13818
13819    #[tokio::test(flavor = "multi_thread")]
13820    async fn an_unauthorized_severing_rotation_never_touches_my_links() {
13821        // The sever tag grants no authority of its own: it rides a rotation the
13822        // adopt gate has already verified. A plain member's forged severing
13823        // rotation is refused whole, so the tag is never even read.
13824        let (bed, owner, me) = TestBed::new();
13825        bed.swap_to(&owner);
13826        let community = create_community(&bed.relay, "NoForge", bed.relays.clone(), None).await.unwrap();
13827        let rid = "b4".repeat(32);
13828        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::CREATE_INVITE), 1).await;
13829        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
13830        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13831        let bundle_json = serde_json::to_string(&bundle).unwrap();
13832        bed.swap_to(&me);
13833        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13834        let _ = follow_control(&bed.relay, &joined).await;
13835        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13836        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
13837
13838        // A stranger with no BAN mints a severing rotation.
13839        let stranger = Keys::generate();
13840        publish_severing_base_rotation(&bed.relay, &joined, &stranger, &[me.keys.public_key(), owner.keys.public_key()], &[0xD8; 32], &joined.community_root).await;
13841
13842        let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
13843        assert!(followed.updated.is_none(), "an unauthorized rotation is not adopted");
13844        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok(), "and my link is untouched");
13845    }
13846
13847    #[tokio::test(flavor = "multi_thread")]
13848    async fn a_degraded_plane_read_is_not_evidence_of_absence() {
13849        // One honest relay is enough to RUN Concord — a sealed, signed rotation is
13850        // proof on its own. What one relay's silence is NOT enough for is concluding
13851        // a rotation does not exist. Those two cases used to be the same empty list,
13852        // so a client that could not reach anyone decided the community had simply
13853        // stopped rotating, and parked. Coverage is what separates them, and it is
13854        // measured against the REACHABLE set, so a single-relay community still gets
13855        // definitive answers.
13856        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13857        let relay = AdversarialRelay::new();
13858        let (bed, owner, me) = TestBed::new();
13859        bed.swap_to(&owner);
13860        let community = create_community(relay.relay(), "Coverage", bed.relays.clone(), None).await.unwrap();
13861        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13862        let bundle_json = serde_json::to_string(&bundle).unwrap();
13863        bed.swap_to(&me);
13864        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
13865        let _ = follow_control(relay.relay(), &joined).await;
13866        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13867
13868        bed.swap_to(&owner);
13869        publish_base_rotation(relay.relay(), &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &[0xE9; 32], &joined.community_root).await;
13870        bed.swap_to(&me);
13871
13872        // The plane cannot be reached at all: an Err, not an empty answer.
13873        let plane = base_rekey_group_key(&joined.community_root, joined.id(), Epoch(1));
13874        relay.on(Match::author(&plane.pk_hex()), Fault::FailFetch("relay down".into()));
13875        let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
13876        let during = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13877        assert_eq!(
13878            during.root_epoch, joined.root_epoch,
13879            "an unreachable plane must not advance anything — but it must also not be \
13880             mistaken for a community that stopped rotating",
13881        );
13882
13883        // The relay comes back. The rotation was there the whole time.
13884        relay.heal();
13885        let _ = follow_rekeys(&relay, &during, &crate::db::current_session()).await;
13886        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13887        assert_eq!(
13888            after.root_epoch, Epoch(1),
13889            "once a relay answers, the rotation it was holding is adopted — the earlier \
13890             silence was never evidence",
13891        );
13892    }
13893
13894    #[tokio::test(flavor = "multi_thread")]
13895    async fn an_older_banlist_edition_can_never_un_ban() {
13896        // Un-banning is the fail-open direction: the un-banned party's rotations and
13897        // messages start being honored again. So a stale edition arriving late — a
13898        // slow relay, a replay, a re-fold under a narrowed window — must never
13899        // overwrite a newer list. Enforced at the storage layer so no future folding
13900        // change can reintroduce it.
13901        let (bed, owner, _me) = TestBed::new();
13902        bed.swap_to(&owner);
13903        let community = create_community(&bed.relay, "BanMonotonic", bed.relays.clone(), None).await.unwrap();
13904        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13905        let villain_keys = Keys::generate();
13906        let villain = villain_keys.public_key().to_hex();
13907
13908        crate::db::community::set_community_banlist(&cid_hex, &[villain.clone()], 5).unwrap();
13909        assert!(crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain));
13910
13911        // An OLDER edition (version 3) tries to replace it with an empty list.
13912        crate::db::community::set_community_banlist(&cid_hex, &[], 3).unwrap();
13913        assert!(
13914            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain),
13915            "a lower-versioned edition must not un-ban",
13916        );
13917        assert!(
13918            crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13919            "and the hot cache must not hold the list we declined to persist",
13920        );
13921
13922        // A NEWER edition legitimately un-bans.
13923        crate::db::community::set_community_banlist(&cid_hex, &[], 6).unwrap();
13924        assert!(
13925            !crate::db::community::is_author_banned(&cid_hex, &villain_keys.public_key()),
13926            "a newer edition still lifts a ban — monotonic, not frozen",
13927        );
13928    }
13929
13930    #[tokio::test(flavor = "multi_thread")]
13931    async fn a_withholding_relay_cannot_wipe_the_banlist_or_the_roster() {
13932        // Absence is not evidence. A relay that serves NO control editions folds an
13933        // empty aggregate, and an empty fold raises no gap flag — nothing is missing
13934        // from a window that contains nothing. Persisting that would un-ban every
13935        // banned member and strip every admin, locally, on one bad fetch. Un-banning
13936        // is the fail-OPEN direction: the un-banned party's rotations start being
13937        // honored again, which is how one client ends up on a branch minted by
13938        // someone everyone else has evicted.
13939        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
13940        let relay = AdversarialRelay::new();
13941        let (bed, owner, _me) = TestBed::new();
13942        bed.swap_to(&owner);
13943        let community = create_community(relay.relay(), "WitholdWipe", bed.relays.clone(), None).await.unwrap();
13944        let rid = "c4".repeat(32);
13945        let admin = Keys::generate();
13946        publish_role(relay.relay(), &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13947        publish_grant(relay.relay(), &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
13948        let villain = Keys::generate();
13949        set_banlist(relay.relay(), &community, &[villain.public_key().to_hex()]).await.unwrap();
13950        let _ = follow_control(&relay, &community).await;
13951
13952        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13953        assert!(
13954            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13955            "the ban folded",
13956        );
13957        let roster_before = crate::db::community::get_community_roles(&cid_hex).unwrap();
13958        assert!(!roster_before.grants.is_empty(), "the admin's grant folded");
13959
13960        // Now the control plane serves nothing at all.
13961        let control_pk = control::ControlPlane::of(&community).pk_hex();
13962        relay.on(Match::author(&control_pk), Fault::Empty);
13963        let _ = follow_control(&relay, &community).await;
13964
13965        assert!(
13966            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&villain.public_key().to_hex()),
13967            "an empty control window must NEVER un-ban — that is the fail-open direction",
13968        );
13969        let roster_after = crate::db::community::get_community_roles(&cid_hex).unwrap();
13970        assert_eq!(
13971            roster_after.grants.len(), roster_before.grants.len(),
13972            "and must never strip standing: an empty fold is absence, not a demotion",
13973        );
13974    }
13975
13976    #[tokio::test(flavor = "multi_thread")]
13977    async fn a_banned_rotators_refounding_is_never_honored() {
13978        // Holding a key is never authority (CORD-06 §Authority). A member banned
13979        // moments ago still holds the current root, so they can mint a perfectly
13980        // shaped Refounding — right prev_commit, right epoch, real blobs. If a
13981        // client honors it, the banned party has just re-keyed the community around
13982        // itself and split every member who folded the ban from every member who
13983        // did not. The banlist has to be consulted at ADOPT time, not only at send.
13984        let (bed, owner, me) = TestBed::new();
13985        bed.swap_to(&owner);
13986        let community = create_community(&bed.relay, "BannedRotator", bed.relays.clone(), None).await.unwrap();
13987        let rid = "a9".repeat(32);
13988        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
13989        let rogue = Keys::generate();
13990        publish_grant(&bed.relay, &community, &owner.keys, &rogue.public_key(), vec![rid], 1).await;
13991        let _ = follow_control(&bed.relay, &community).await;
13992
13993        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
13994        let bundle_json = serde_json::to_string(&bundle).unwrap();
13995        bed.swap_to(&me);
13996        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
13997        let _ = follow_control(&bed.relay, &joined).await;
13998        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
13999
14000        // The rogue admin is banned, and we fold that ban.
14001        bed.swap_to(&owner);
14002        set_banlist(&bed.relay, &community, &[rogue.public_key().to_hex()]).await.unwrap();
14003        bed.swap_to(&me);
14004        let _ = follow_control(&bed.relay, &joined).await;
14005        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14006        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14007        assert!(
14008            crate::db::community::get_community_banlist(&cid_hex).unwrap().contains(&rogue.public_key().to_hex()),
14009            "the ban folded before the rotation arrives",
14010        );
14011
14012        // Still holding the root, the banned admin mints a Refounding anyway.
14013        bed.swap_to(&owner);
14014        publish_base_rotation(
14015            &bed.relay, &joined, &rogue,
14016            &[owner.keys.public_key(), me.keys.public_key(), rogue.public_key()],
14017            &[0xBA; 32], &joined.community_root,
14018        ).await;
14019
14020        bed.swap_to(&me);
14021        let held_before = joined.root_epoch;
14022        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14023        assert!(!follow.self_removed, "a banned rotator can never remove anyone");
14024        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14025        assert_eq!(
14026            after.root_epoch, held_before,
14027            "a banned member's rotation must not move the epoch — honoring it hands the community to the party just evicted",
14028        );
14029        assert_eq!(after.community_root, joined.community_root, "and must not move the root");
14030    }
14031
14032    #[tokio::test(flavor = "multi_thread")]
14033    async fn a_rekey_exclusion_keeps_my_epoch_keys() {
14034        // Being removed must not cost me the ability to scrub my own history. The
14035        // epoch keys open no FUTURE epoch — post-removal keys are never delivered —
14036        // so retaining them leaks nothing; they are only what lets me author a 3305
14037        // self-delete of messages I already sent, each sealed under the epoch it was
14038        // sent at. Every other self-removal path retains them. The rekey-exclusion
14039        // path silently did not, so an eviction (or a false positive) destroyed that.
14040        let (bed, owner, me) = TestBed::new();
14041        bed.swap_to(&owner);
14042        let community = create_community(&bed.relay, "ExclusionKeys", bed.relays.clone(), None).await.unwrap();
14043        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14044        let bundle_json = serde_json::to_string(&bundle).unwrap();
14045        bed.swap_to(&me);
14046        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14047        let _ = follow_control(&bed.relay, &joined).await;
14048        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14049        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14050        assert!(
14051            !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
14052            "the joiner archived its epoch key",
14053        );
14054
14055        // The owner rotates and leaves me out — a genuine, authorized removal.
14056        bed.swap_to(&owner);
14057        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key()], &[0xD1; 32], &joined.community_root).await;
14058
14059        bed.swap_to(&me);
14060        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14061        assert!(follow.self_removed, "an authorized rotation that excluded me IS a removal");
14062
14063        // The teardown the caller performs on self_removed.
14064        crate::db::community::delete_community_retain_keys(&cid_hex).unwrap();
14065        assert!(
14066            crate::db::community::load_community_v2(joined.id()).unwrap().is_none(),
14067            "the community is gone",
14068        );
14069        assert!(
14070            !crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(),
14071            "but my epoch keys survive, so I can still self-delete what I wrote",
14072        );
14073    }
14074
14075    #[tokio::test(flavor = "multi_thread")]
14076    async fn a_client_that_adopted_the_losing_fork_converges_down_onto_the_canonical_one() {
14077        // Two authorized rotators mint the same epoch concurrently. The tiebreak is
14078        // deterministic — lowest new base key wins — but it only ever ran across
14079        // rotations seen in ONE fetch. A client that saw only the higher sibling
14080        // adopted it and then looked exclusively for its successor, so the canonical
14081        // branch became permanently unreachable: two halves of a community at the
14082        // same epoch number, different keys, unable to read each other, with nobody
14083        // misbehaving. Convergence has to be re-checked, not decided once.
14084        let (bed, owner, me) = TestBed::new();
14085        bed.swap_to(&owner);
14086        let community = create_community(&bed.relay, "ForkHeal", bed.relays.clone(), None).await.unwrap();
14087        let rid = "f1".repeat(32);
14088        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14089        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14090        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14091        let bundle_json = serde_json::to_string(&bundle).unwrap();
14092        bed.swap_to(&me);
14093        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14094        let _ = follow_control(&bed.relay, &joined).await;
14095        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14096
14097        // TWO rotators, which is what a real fork requires: rotations correlate on
14098        // (rotator, scope, epoch, prevcommit), so one rotator minting twice would
14099        // merge into a single rotation rather than fork — and `mint_or_reuse` stops
14100        // an honest rotator doing that anyway. A second BAN-holder is the genuine
14101        // concurrent-refound case. Low wins the spec's tiebreak.
14102        let rival = Keys::generate();
14103        bed.swap_to(&owner);
14104        publish_grant(&bed.relay, &community, &owner.keys, &rival.public_key(), vec!["f1".repeat(32)], 1).await;
14105        // The owner's client must fold the grant before minting the rival's rotation:
14106        // a non-owner rotation carries a `vac` citing the Grant it acts under, and an
14107        // uncited one is inadmissible by design.
14108        let _ = follow_control(&bed.relay, &community).await;
14109        bed.swap_to(&me);
14110        let _ = follow_control(&bed.relay, &joined).await;
14111        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14112
14113        let low = [0x11u8; 32];
14114        let high = [0xF1u8; 32];
14115        let recipients = [owner.keys.public_key(), me.keys.public_key(), rival.public_key()];
14116        bed.swap_to(&owner);
14117        publish_base_rotation(&bed.relay, &joined, &owner.keys, &recipients, &high, &joined.community_root).await;
14118
14119        // We see ONLY the high sibling first and adopt it — the fork is now set.
14120        bed.swap_to(&me);
14121        let session = crate::db::current_session();
14122        let _ = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
14123        let forked = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14124        assert_eq!(forked.root_epoch, Epoch(1), "adopted the sibling we could see");
14125        assert_eq!(forked.community_root, high, "and it is the LOSING branch");
14126
14127        // The rival BAN-holder's sibling surfaces afterwards — a separate rotation,
14128        // and the one the deterministic tiebreak says everyone must land on.
14129        bed.swap_to(&owner);
14130        publish_base_rotation(&bed.relay, &joined, &rival, &recipients, &low, &joined.community_root).await;
14131
14132        bed.swap_to(&me);
14133        let _ = follow_rekeys(&bed.relay, &forked, &session).await.unwrap();
14134        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14135        assert_eq!(
14136            healed.community_root, low,
14137            "a settled epoch MUST converge down onto the canonical sibling, or the community stays split",
14138        );
14139        assert_eq!(healed.root_epoch, Epoch(1), "same epoch — this is a sideways step, not an advance");
14140
14141        // Down-only: re-running must not bounce back up to the higher sibling, or two
14142        // clients seeing the pair in different orders would oscillate forever.
14143        let _ = follow_rekeys(&bed.relay, &healed, &session).await.unwrap();
14144        let settled = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14145        assert_eq!(settled.community_root, low, "convergence is down-only and stable");
14146    }
14147
14148    #[tokio::test(flavor = "multi_thread")]
14149    async fn a_deep_catch_up_never_loses_ground_and_lands_on_the_head() {
14150        // The scenario this program exists for: a member returns after a community
14151        // rotated hard while they were away, on a link that keeps dropping.
14152        //
14153        // This asserts MONOTONICITY and CONVERGENCE — the epoch never moves
14154        // backward across passes, and the client ends exactly on the head, not one
14155        // short and not overshooting. Retention of a partial walk is a different
14156        // property with its own test (see the interrupted-mid-walk case, which fails
14157        // without per-hop banking); this one would still pass without banking, and
14158        // is not claiming otherwise.
14159        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
14160        const HOPS: u64 = 12;
14161
14162        let relay = AdversarialRelay::new();
14163        let (bed, owner, me) = TestBed::new();
14164        bed.swap_to(&owner);
14165        let community = create_community(relay.relay(), "DeepStorm", bed.relays.clone(), None).await.unwrap();
14166        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14167        let bundle_json = serde_json::to_string(&bundle).unwrap();
14168        bed.swap_to(&me);
14169        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
14170        let _ = follow_control(relay.relay(), &joined).await;
14171        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14172
14173        // The community rotates HOPS times while we are offline. Owner-signed, so
14174        // authority is never the variable under test here — reachability is.
14175        bed.swap_to(&owner);
14176        let recipients = [owner.keys.public_key(), me.keys.public_key()];
14177        let mut cur = community.clone();
14178        let mut planes: Vec<String> = Vec::new();
14179        for i in 1..=HOPS {
14180            let root = [(0x40 + i) as u8; 32];
14181            planes.push(base_rekey_group_key(&cur.community_root, cur.id(), Epoch(i)).pk_hex());
14182            publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, &root, &cur.community_root).await;
14183            cur.community_root = root;
14184            cur.root_epoch = Epoch(i);
14185        }
14186
14187        // Come back into a storm. The failures are placed MID-WALK on purpose: each
14188        // pass gets several hops in before dying, so a walk that does not bank as it
14189        // goes loses a batch of work every time and can never get past the first
14190        // barrier. Failing only the first ask of each barrier means the link is
14191        // genuinely recovering, so the only question is whether progress survives.
14192        bed.swap_to(&me);
14193        for barrier in [5usize, 9] {
14194            relay.on(Match::author(&planes[barrier - 1]).nth(1), Fault::FailFetch("flaky link".into()));
14195        }
14196
14197        let session = crate::db::current_session();
14198        let mut last = 0u64;
14199        // Bounded retries — the point is that each pass strictly advances, so a
14200        // finite number of them suffices however unlucky the link is.
14201        for pass in 0..(HOPS * 3) {
14202            let held = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14203            let now = held.root_epoch.0;
14204            assert!(
14205                now >= last,
14206                "pass {pass}: epoch went BACKWARD ({last} -> {now}); a catch-up must never lose ground",
14207            );
14208            last = now;
14209            if now == HOPS {
14210                break;
14211            }
14212            let _ = follow_rekeys(&relay, &held, &session).await;
14213        }
14214
14215        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14216        assert_eq!(
14217            healed.root_epoch, Epoch(HOPS),
14218            "a deep backlog on a failing link must still converge on the head; stuck at {}",
14219            healed.root_epoch.0,
14220        );
14221    }
14222
14223    #[tokio::test(flavor = "multi_thread")]
14224    async fn a_missing_chunk_does_not_block_adopting_the_key_i_already_hold() {
14225        // Completeness gates ADOPTION today, not just removal. A 1,000-member
14226        // rotation spans thirteen chunks, so one unreachable chunk parks every
14227        // member of the community — including everyone whose key is sitting in a
14228        // chunk they already fetched and can open right now. Requiring the whole set
14229        // to adopt makes a large community's rotation as fragile as its worst chunk,
14230        // and the walk is sequential, so the strand blocks every epoch above it too.
14231        //
14232        // Completeness is required to conclude REMOVAL, which is destructive and
14233        // must stay conservative. Adoption is the safe direction: the blob is bound
14234        // to (scope, epoch) inside its ciphertext, so a key that opens is the key
14235        // for this rotation whatever else is missing.
14236        let (bed, owner, me) = TestBed::new();
14237        bed.swap_to(&owner);
14238        let community = create_community(&bed.relay, "PartialChunks", bed.relays.clone(), None).await.unwrap();
14239        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14240        let bundle_json = serde_json::to_string(&bundle).unwrap();
14241        bed.swap_to(&me);
14242        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14243        let _ = follow_control(&bed.relay, &joined).await;
14244        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14245
14246        // A two-chunk rotation where only the chunk carrying MY blob is published.
14247        bed.swap_to(&owner);
14248        let new_root = [0xC7u8; 32];
14249        let new_epoch = Epoch(joined.root_epoch.0 + 1);
14250        let prev_commit = super::super::derive::epoch_key_commitment(joined.root_epoch, &joined.community_root);
14251        let group = base_rekey_group_key(&joined.community_root, joined.id(), new_epoch);
14252        let my_blob = rekey::build_blob_local(
14253            owner.keys.secret_key(), &owner.keys.public_key().to_bytes(),
14254            &me.keys.public_key(), RekeyScope::Root, new_epoch, &new_root,
14255        ).unwrap();
14256        // Declares 2 chunks; only chunk 1 ever reaches the relay.
14257        let rumor = rekey::build_rekey_rumor(
14258            owner.keys.public_key(), RekeyScope::Root, new_epoch, joined.root_epoch,
14259            &prev_commit, &[my_blob], 1, 2, 2_000,
14260            my_authority_citation(&joined, &owner.keys.public_key()).as_ref(), false,
14261        ).unwrap();
14262        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
14263        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14264
14265        bed.swap_to(&me);
14266        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14267        assert!(!follow.self_removed, "a missing chunk is never a removal");
14268        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14269        assert_eq!(
14270            after.root_epoch, new_epoch,
14271            "my blob was present and opened — the missing sibling chunk says nothing about MY key, \
14272             and parking here strands the whole community behind its least reachable chunk",
14273        );
14274    }
14275
14276    #[tokio::test(flavor = "multi_thread")]
14277    async fn an_empty_rotation_is_never_a_mass_removal() {
14278        // `is_complete` is satisfied by a single chunk declaring n=1 — including one
14279        // carrying ZERO blobs, which the builders emit verbatim for an empty
14280        // recipient set. Read literally that is "a complete, authorized rotation
14281        // containing no blob for me", i.e. removal — and the removal path DELETES the
14282        // community locally, keys and all. From the owner, one such rotation would
14283        // evict every member of the community at once, irreversibly.
14284        //
14285        // Nobody can be removed by a rotation that removed everybody.
14286        let (bed, owner, me) = TestBed::new();
14287        bed.swap_to(&owner);
14288        let community = create_community(&bed.relay, "EmptyRotation", bed.relays.clone(), None).await.unwrap();
14289        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14290        let bundle_json = serde_json::to_string(&bundle).unwrap();
14291        bed.swap_to(&me);
14292        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14293        let _ = follow_control(&bed.relay, &joined).await;
14294        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14295
14296        // The owner publishes a complete rotation with no recipients at all.
14297        bed.swap_to(&owner);
14298        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[], &[0xB0; 32], &joined.community_root).await;
14299
14300        bed.swap_to(&me);
14301        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14302        assert!(
14303            !follow.self_removed,
14304            "an all-empty rotation must NEVER read as my removal — that path deletes the community",
14305        );
14306        assert!(
14307            crate::db::community::load_community_v2(joined.id()).unwrap().is_some(),
14308            "and the community must still be held",
14309        );
14310    }
14311
14312    #[tokio::test(flavor = "multi_thread")]
14313    async fn a_catch_up_interrupted_mid_walk_keeps_the_hops_it_already_took() {
14314        // A client back from a week offline walks many epochs in one pass. Today the
14315        // community row is written ONCE, after the loop, so a fetch failure on the
14316        // last hop discards every hop already walked — the next attempt starts from
14317        // zero, costs the same again, and on a flaky link may never converge at all.
14318        // Progress must be banked per hop.
14319        use crate::community::transport::adversarial::{AdversarialRelay, Fault, Match};
14320        // The bed supplies isolated per-account DBs; the adversarial relay replaces
14321        // its transport so a fault plan governs what the client can read back.
14322        let relay = AdversarialRelay::new();
14323        let (bed, owner, me) = TestBed::new();
14324        bed.swap_to(&owner);
14325        let community = create_community(relay.relay(), "DeepCatchUp", bed.relays.clone(), None).await.unwrap();
14326
14327        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14328        let bundle_json = serde_json::to_string(&bundle).unwrap();
14329        bed.swap_to(&me);
14330        let joined = accept_parked_invite(relay.relay(), &bundle_json, None).await.unwrap();
14331        let _ = follow_control(relay.relay(), &joined).await;
14332        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14333
14334        // Owner rotates three times while we are away. Owner rotations need no roster.
14335        bed.swap_to(&owner);
14336        let mut cur = community.clone();
14337        let roots = [[0xA1u8; 32], [0xA2; 32], [0xA3; 32]];
14338        let recipients = [owner.keys.public_key(), me.keys.public_key()];
14339        for (i, root) in roots.iter().enumerate() {
14340            publish_base_rotation(relay.relay(), &cur, &owner.keys, &recipients, root, &cur.community_root).await;
14341            cur.community_root = *root;
14342            cur.root_epoch = Epoch(i as u64 + 1);
14343        }
14344
14345        // Come back, but the plane for the LAST hop is unreachable.
14346        bed.swap_to(&me);
14347        let third_hop = base_rekey_group_key(&roots[1], joined.id(), Epoch(3));
14348        relay.on(Match::author(&third_hop.pk_hex()), Fault::FailFetch("relay dropped".into()));
14349
14350        let _ = follow_rekeys(&relay, &joined, &crate::db::current_session()).await;
14351
14352        let after = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14353        assert!(
14354            after.root_epoch.0 >= 2,
14355            "hops 1 and 2 succeeded and MUST be banked; an interrupted walk that reverts to epoch {} \
14356             restarts from scratch every attempt and may never converge on a flaky link",
14357            after.root_epoch.0,
14358        );
14359
14360        // Faults cease: the walk resumes from where it banked and reaches the head.
14361        relay.heal();
14362        let _ = follow_rekeys(&relay, &after, &crate::db::current_session()).await;
14363        let healed = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14364        assert_eq!(healed.root_epoch, Epoch(3), "once faults cease the walk converges on the head");
14365    }
14366
14367    #[tokio::test(flavor = "multi_thread")]
14368    async fn a_promote_is_reported_as_an_authority_change() {
14369        // The single line whose absence was the fork. A promote moves the ROSTER,
14370        // which lives in its own column and never appears in the returned document —
14371        // so a caller keying its retry off `updated` alone sees "nothing happened",
14372        // skips the re-walk, and leaves the rekey pass that just refused this admin's
14373        // rotation to never reconsider it. Asserted at the unit level deliberately:
14374        // an integration drill can pass for the wrong reason (a roster cached on some
14375        // earlier pass), and this codebase has been bitten by exactly that before.
14376        let (bed, owner, admin) = TestBed::new();
14377        bed.swap_to(&owner);
14378        let community = create_community(&bed.relay, "PromoteReports", bed.relays.clone(), None).await.unwrap();
14379        follow_control(&bed.relay, &community).await.unwrap(); // settle the baseline
14380
14381        let rid = "e1".repeat(32);
14382        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14383        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14384
14385        let control = follow_control(&bed.relay, &community).await.unwrap();
14386        assert!(
14387            control.authority_changed,
14388            "a promote MUST report an authority change — this is what re-runs the rekey walk",
14389        );
14390        assert!(control.moved(), "and therefore counts as movement");
14391        assert!(
14392            control.updated.is_none(),
14393            "while moving no document at all — which is precisely why `updated` alone was blind",
14394        );
14395    }
14396
14397    #[tokio::test(flavor = "multi_thread")]
14398    async fn a_seeded_roster_never_vetoes_a_coherent_control_fold() {
14399        // `merge_local_roster` seeds an optimistic roster at `roles_at = 0` so a
14400        // just-created role works before its edition folds. That seed names entities
14401        // the plane may never head, which made `stored_complete` false FOREVER — the
14402        // roster could never be replaced, and a roster that never updates cannot
14403        // authorize anyone, so the client silently refused every rotation that admin
14404        // published. It landed on owners, who seed the most.
14405        let (bed, owner, admin) = TestBed::new();
14406        bed.swap_to(&owner);
14407        let community = create_community(&bed.relay, "SeedVeto", bed.relays.clone(), None).await.unwrap();
14408        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14409        let rid = "c1".repeat(32);
14410        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14411        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14412
14413        // A seed naming an entity the plane will never head, with NO provenance —
14414        // exactly what creating a private channel leaves behind.
14415        let ghost = "d4".repeat(32);
14416        let mut seeded = crate::community::roles::CommunityRoles::default();
14417        seeded.roles.push(admin_role(&ghost, Permissions::MANAGE_CHANNELS));
14418        seeded.grants.push(crate::community::roles::MemberGrant {
14419            member: owner.keys.public_key().to_hex(),
14420            role_ids: vec![ghost.clone()],
14421        });
14422        crate::db::community::set_community_roles(&cid_hex, &seeded, 0).unwrap();
14423        // A floor for the ghost is what turns `stored_complete` false.
14424        crate::db::community::set_edition_head_at_epoch(&cid_hex, &ghost, 1, &[0u8; 32], &[0u8; 32], community.root_epoch.0).unwrap();
14425
14426        follow_control(&bed.relay, &community).await.unwrap().updated;
14427
14428        let folded = crate::db::community::get_community_roles(&cid_hex).unwrap();
14429        assert!(
14430            folded.grants.iter().any(|g| g.member == admin.keys.public_key().to_hex()),
14431            "a coherent fold must replace a provenance-less seed, or the client is deaf to this admin forever",
14432        );
14433        assert_ne!(
14434            crate::db::community::get_community_roles_at(&cid_hex).unwrap(),
14435            0,
14436            "and the replacement carries the plane's provenance",
14437        );
14438    }
14439
14440    #[tokio::test(flavor = "multi_thread")]
14441    async fn a_pending_severance_heals_on_a_quiet_follow_pass() {
14442        // The retry must run on passes that adopt NOTHING. A one-shot transport
14443        // blip during the original severance otherwise has no healing trigger
14444        // until some future rotation happens to be adopted — and until then the
14445        // community reads Public, which silently strips the refound from every
14446        // ordinary ban.
14447        let (bed, owner, me) = TestBed::new();
14448        bed.swap_to(&owner);
14449        let community = create_community(&bed.relay, "QuietHeal", bed.relays.clone(), None).await.unwrap();
14450        let rid = "b5".repeat(32);
14451        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14452        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14453        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14454        let bundle_json = serde_json::to_string(&bundle).unwrap();
14455        bed.swap_to(&me);
14456        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14457        let _ = follow_control(&bed.relay, &joined).await;
14458        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14459        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14460
14461        // A containment was adopted but its revoke was interrupted: the mark is
14462        // down, the epoch-0 link still stands.
14463        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
14464        add_sever_mark(&cid_hex, 1, 5_000);
14465        assert!(fetch_public_bundle(&bed.relay, &minted.url).await.is_ok());
14466
14467        // A QUIET follow — no rotation anywhere — must finish the burial.
14468        let followed = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14469        assert!(followed.updated.is_none(), "nothing to adopt");
14470        assert!(
14471            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14472            "the interrupted severance healed with no rotation to carry it",
14473        );
14474    }
14475
14476    #[tokio::test(flavor = "multi_thread")]
14477    async fn a_sever_survives_a_later_ordinary_rotation_in_the_same_walk() {
14478        // Sticky severance: a client catching up walks EVERY missed rotation in
14479        // one pass. If the containment hop is followed by an ordinary one,
14480        // last-hop-wins semantics would refresh the links right back open — the
14481        // door the raid came through, re-armed with the newest root.
14482        let (bed, owner, me) = TestBed::new();
14483        bed.swap_to(&owner);
14484        let community = create_community(&bed.relay, "StickySever", bed.relays.clone(), None).await.unwrap();
14485        let rid = "b6".repeat(32);
14486        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14487        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14488        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14489        let bundle_json = serde_json::to_string(&bundle).unwrap();
14490        bed.swap_to(&me);
14491        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14492        let _ = follow_control(&bed.relay, &joined).await;
14493        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14494        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14495
14496        // Hop 1: the containment. Hop 2: an ordinary rotation on top of it.
14497        let root1 = [0xE1; 32];
14498        publish_severing_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root1, &joined.community_root).await;
14499        let mut stepped = joined.clone();
14500        stepped.community_root = root1;
14501        stepped.root_epoch = Epoch(1);
14502        let root2 = [0xE2; 32];
14503        publish_base_rotation(&bed.relay, &stepped, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &root2, &root1).await;
14504
14505        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated.expect("both hops adopt");
14506        assert_eq!(updated.root_epoch, Epoch(2), "the walk reached the ordinary head");
14507        assert!(
14508            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14509            "the containment's burial survived the ordinary hop behind it",
14510        );
14511    }
14512
14513    /// Did the severing refound vend `who` a key? Opens the base rotation and looks
14514    /// for a blob at their locator.
14515    async fn vended_to(bed: &TestBed, before: &CommunityV2, who: &Keys) -> bool {
14516        let next = Epoch(before.root_epoch.0 + 1);
14517        let group = base_rekey_group_key(&before.community_root, before.id(), next);
14518        let chunks = fetch_rekey_chunks(&bed.relay, &before.relays, &group).await.unwrap();
14519        rekey::collect_rotations(&chunks).iter().any(|r| {
14520            rekey::find_my_blobs(
14521                &r.blobs,
14522                &r.rotator.to_bytes(),
14523                &who.public_key().to_bytes(),
14524                RekeyScope::Root,
14525                next,
14526            )
14527            .next()
14528            .is_some()
14529        })
14530    }
14531
14532    /// Local guestbook row for `who`, claiming `at_ms` but OBSERVED at `seen`.
14533    fn local_join_observed(who: &Keys, at_ms: u64, seen: u64, epoch: u64, tag: u8) -> guestbook::GuestbookEvent {
14534        guestbook::GuestbookEvent {
14535            rumor_id: [tag; 32],
14536            entry: guestbook::GuestbookEntry::Join { member: who.public_key(), invited_by: None, at_ms },
14537            epoch: Some(epoch),
14538            observed_at: Some(seen),
14539        }
14540    }
14541
14542    #[tokio::test(flavor = "multi_thread")]
14543    async fn a_backdated_join_cannot_slide_under_the_window_when_we_watched_it_arrive() {
14544        // `at_ms` is author-claimed, so a raider can put it a year in the past and pass
14545        // any window computed from the claim alone. What this client WATCHED arrive is
14546        // not theirs to write.
14547        let (bed, owner, _o) = TestBed::new();
14548        bed.swap_to(&owner);
14549        let community = create_community(&bed.relay, "Backdate", bed.relays.clone(), None).await.unwrap();
14550        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14551        let liar = Keys::generate();
14552
14553        // Claims it joined long before the window; we saw it arrive INSIDE the window.
14554        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14555        let join = guestbook::build_join_rumor(liar.public_key(), None, 1_000);
14556        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &liar, Timestamp::from_secs(1)).unwrap();
14557        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14558        crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&liar, 1_000, 9_000, community.root_epoch.0, 3)], 0).unwrap();
14559        // We were following well before the window opened.
14560        crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "100".into()).unwrap();
14561
14562        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14563
14564        assert!(
14565            !vended_to(&bed, &community, &liar).await,
14566            "a backdated Join must not beat the arrival we watched with our own clock",
14567        );
14568    }
14569
14570    #[tokio::test(flavor = "multi_thread")]
14571    async fn a_client_that_started_watching_late_evicts_nobody() {
14572        // Same row, same claim, same observation — but this client only began following
14573        // the guestbook AFTER the window opened, so everything looks new to it. Its
14574        // observations prove nothing, and acting on them would evict the community.
14575        let (bed, owner, _o) = TestBed::new();
14576        bed.swap_to(&owner);
14577        let community = create_community(&bed.relay, "LateWatcher", bed.relays.clone(), None).await.unwrap();
14578        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14579        let quiet = Keys::generate();
14580
14581        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14582        let join = guestbook::build_join_rumor(quiet.public_key(), None, 1_000);
14583        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &quiet, Timestamp::from_secs(1)).unwrap();
14584        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14585        crate::db::community::set_guestbook(&cid_hex, &[local_join_observed(&quiet, 1_000, 9_000, community.root_epoch.0, 4)], 0).unwrap();
14586        // Coverage begins INSIDE the window — we were not here before it.
14587        crate::db::settings::set_sql_setting(format!("gb_observed_since:{cid_hex}"), "7000".into()).unwrap();
14588
14589        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14590
14591        assert!(
14592            vended_to(&bed, &community, &quiet).await,
14593            "without coverage predating the window our observations prove nothing — an \
14594             old member must not be evicted for having been noticed late",
14595        );
14596    }
14597
14598    #[tokio::test(flavor = "multi_thread")]
14599    async fn a_purge_vends_only_to_the_retain_list_however_late_a_stranger_arrives() {
14600        // The Admin Terminal hands over a RETAIN list. Internally that used to become a
14601        // removal list computed from the memberlist at one instant, while the refound
14602        // fetched its own later — so an account appearing in between was on neither
14603        // list and got a key by DEFAULT, implicitly retained by the purge meant to be
14604        // exhaustive. An allow-list closes it with no window and nothing to backdate.
14605        let (bed, owner, _o) = TestBed::new();
14606        bed.swap_to(&owner);
14607        let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
14608        let kept = Keys::generate();
14609        let stranger = Keys::generate();
14610        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14611        // The stranger even BACKDATES their Join — an allow-list does not care.
14612        for (who, at_ms) in [(&kept, 2_000_000u64), (&stranger, 1_000u64)] {
14613            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14614            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14615            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14616        }
14617
14618        // The operator selected exactly one member. Nobody named the stranger.
14619        refound_community_retaining(&bed.relay, &community, &[], &[kept.public_key()]).await.unwrap();
14620
14621        assert!(
14622            vended_to(&bed, &community, &kept).await,
14623            "the selected member keeps their key",
14624        );
14625        assert!(
14626            !vended_to(&bed, &community, &stranger).await,
14627            "an account not on the retain list is vended NO key, no matter when it \
14628             appeared or what Join time it claims",
14629        );
14630    }
14631
14632    #[tokio::test(flavor = "multi_thread")]
14633    async fn a_raid_window_joiner_is_vended_no_key_even_if_nobody_named_them() {
14634        // The recipient set is a LIVE fetch taken AFTER the cut was computed, and
14635        // re-taken on every retry. A bot arriving in that gap is in the memberlist and
14636        // absent from the cut — and was handed a working key for the very epoch meant
14637        // to evict it. Testing join TIME catches it with nobody naming it.
14638        let (bed, owner, _o) = TestBed::new();
14639        bed.swap_to(&owner);
14640        let community = create_community(&bed.relay, "RaceCut", bed.relays.clone(), None).await.unwrap();
14641        let established = Keys::generate();
14642        let late = Keys::generate();
14643        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14644        for (who, at_ms) in [(&established, 2_000_000u64), (&late, 9_000_000)] {
14645            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14646            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14647            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14648        }
14649
14650        // Window opens at 5_000s. `late` is named by NOBODY — the cut is empty.
14651        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14652
14653        assert!(
14654            !vended_to(&bed, &community, &late).await,
14655            "a raid-window joiner must be vended NO key, even when nobody named them",
14656        );
14657        assert!(
14658            vended_to(&bed, &community, &established).await,
14659            "and the member who was already here keeps theirs",
14660        );
14661    }
14662
14663    #[tokio::test(flavor = "multi_thread")]
14664    async fn a_member_whose_join_is_not_on_the_plane_is_never_read_as_a_late_joiner() {
14665        // THE regression that matters. A quiet member's Join lives on a buried epoch,
14666        // so the reachable guestbook has no trace of them at all. Absence must read as
14667        // OLD, never as new — reading it the other way shed 39 real members across five
14668        // rotations. Here the Join exists ONLY locally, exactly as a buried one does.
14669        let (bed, owner, _o) = TestBed::new();
14670        bed.swap_to(&owner);
14671        let community = create_community(&bed.relay, "Buried", bed.relays.clone(), None).await.unwrap();
14672        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14673        let quiet = Keys::generate();
14674        let local_join = guestbook::GuestbookEvent {
14675            rumor_id: [7u8; 32],
14676            entry: guestbook::GuestbookEntry::Join { member: quiet.public_key(), invited_by: None, at_ms: 9_900_000 },
14677            epoch: Some(community.root_epoch.0),
14678            observed_at: None,
14679        };
14680        crate::db::community::set_guestbook(&cid_hex, &[local_join], 0).unwrap();
14681
14682        // Even stamped INSIDE the window, a Join the plane does not serve must not evict.
14683        refound_community_severing(&bed.relay, &community, &[], 5_000).await.unwrap();
14684
14685        assert!(
14686            vended_to(&bed, &community, &quiet).await,
14687            "no Join on the reachable plane means OLD — absence of evidence must never evict",
14688        );
14689    }
14690
14691    #[tokio::test(flavor = "multi_thread")]
14692    async fn contain_raid_bans_cuts_and_severs_end_to_end() {
14693        let (bed, owner, _other) = TestBed::new();
14694        bed.swap_to(&owner);
14695        let community = create_community(&bed.relay, "Containment", bed.relays.clone(), None).await.unwrap();
14696        let minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
14697
14698        // Three arrivals: an established member (before the window), a convicted
14699        // raider and a quiet lurker (both inside it).
14700        let established = Keys::generate();
14701        let raider = Keys::generate();
14702        let lurker = Keys::generate();
14703        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
14704        for (who, at_ms) in [(&established, 2_000_000u64), (&raider, 9_000_000), (&lurker, 9_500_000)] {
14705            let join = guestbook::build_join_rumor(who.public_key(), None, at_ms);
14706            let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, who, Timestamp::from_secs(at_ms / 1000)).unwrap();
14707            bed.relay.publish(&wrap, &bed.relays).await.unwrap();
14708        }
14709
14710        let report = contain_raid(&bed.relay, community.id(), &[raider.public_key()], 5_000, &[]).await.unwrap();
14711        assert_eq!(report.banned, 1, "the convicted raider is banned");
14712        assert_eq!(report.window_cut, 1, "the lurker who joined inside the window is cut");
14713        assert!(report.refound_ok, "the severing refound landed: {:?}", report.warnings);
14714        assert_eq!((report.epoch_before, report.epoch_after), (0, 1), "the root rolled");
14715        assert_eq!(report.own_links_revoked, 1, "the door closed with it");
14716
14717        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14718        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
14719        assert!(banlist.contains(&raider.public_key().to_hex()), "raider on the banlist");
14720        assert!(!banlist.contains(&lurker.public_key().to_hex()), "the lurker is cut, never banned — they can rejoin through a fresh link");
14721        assert!(!banlist.contains(&established.public_key().to_hex()));
14722        assert!(
14723            fetch_public_bundle(&bed.relay, &minted.url).await.unwrap_err().contains("revoked"),
14724            "the invite URL the raid used is a grave",
14725        );
14726
14727        // The rotation's recipient set is the severance line: the established
14728        // member rides into the new epoch, the raider and the lurker do not.
14729        let updated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
14730        assert_eq!(updated.root_epoch, Epoch(1));
14731        let members = memberlist(&bed.relay, &updated).await.unwrap();
14732        assert!(members.contains(&established.public_key()), "established member survives");
14733        assert!(!members.contains(&raider.public_key()), "raider gone");
14734    }
14735
14736    #[tokio::test(flavor = "multi_thread")]
14737    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
14738        // CORD-05 §2: a link shared once keeps working across rotations, because
14739        // its bundle is re-posted behind the same URL. The Refounder can only
14740        // refresh the bundles they hold signer secrets for — their OWN — so
14741        // every other creator has to heal their links when they ADOPT the
14742        // rotation. Without that, an admin's links keep vending the superseded
14743        // root and drop new joiners onto a dead epoch, which is precisely the
14744        // stranding the stable-URL refresh exists to prevent.
14745        let (bed, owner, me) = TestBed::new();
14746        bed.swap_to(&owner);
14747        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
14748        let rid = "b1".repeat(32);
14749        // CREATE_INVITE too: minting is offer-gated by the same bit readers use.
14750        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
14751        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14752
14753        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14754        let bundle_json = serde_json::to_string(&bundle).unwrap();
14755        bed.swap_to(&me);
14756        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14757        let _ = follow_control(&bed.relay, &joined).await;
14758        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14759
14760        // I mint a link of my own at the CURRENT epoch.
14761        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
14762        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14763        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
14764
14765        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
14766        // its signer secret.
14767        let new_root = [0xD4; 32];
14768        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14769
14770        let updated = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap().updated
14771            .expect("the owner's Refounding is adopted");
14772        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
14773
14774        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
14775        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
14776        assert_eq!(
14777            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
14778            new_root,
14779            "and the new root behind the same URL",
14780        );
14781    }
14782
14783    #[tokio::test]
14784    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
14785        // Authority escalation: a BAN-admin can't use a Refounding to evict the
14786        // OWNER (no one outranks the owner). Excluding them makes the rotation
14787        // inadmissible — members fork-reject it rather than migrate to the coup.
14788        let (bed, owner, me) = TestBed::new();
14789        let admin = Keys::generate();
14790        bed.swap_to(&owner);
14791        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
14792        let rid = "b0".repeat(32);
14793        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14794        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
14795
14796        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14797        let bundle_json = serde_json::to_string(&bundle).unwrap();
14798        bed.swap_to(&me);
14799        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14800        let _ = follow_control(&bed.relay, &joined).await;
14801        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14802
14803        // The admin re-founds delivering to me but NOT the owner — a takeover.
14804        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
14805        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14806        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
14807    }
14808
14809    #[tokio::test]
14810    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
14811        // Authority escalation: two equal-rank BAN-admins — neither strictly
14812        // outranks the other, so one can't Refound the other out. Excluding a
14813        // peer makes the rotation inadmissible.
14814        let (bed, owner, me) = TestBed::new();
14815        let admin_a = Keys::generate();
14816        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
14817        bed.swap_to(&owner);
14818        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
14819        let rid = "b0".repeat(32);
14820        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14821        // Both A and B hold the SAME role (same position 1) → peers.
14822        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
14823        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
14824
14825        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14826        let bundle_json = serde_json::to_string(&bundle).unwrap();
14827        bed.swap_to(&me);
14828        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14829        let _ = follow_control(&bed.relay, &joined).await;
14830        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14831
14832        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
14833        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
14834
14835        let follow = follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14836        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
14837    }
14838
14839    #[tokio::test]
14840    async fn a_retried_refounding_reuses_the_same_root() {
14841        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
14842        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
14843        let (_tmp, _guard, _owner) = init_test_db();
14844        let relay = MemoryRelay::new();
14845        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
14846        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
14847        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14848        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
14849        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
14850    }
14851
14852    #[tokio::test]
14853    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
14854        // CORD-04 §2 rank inversion. Minting at a position you outrank is
14855        // necessary but NOT sufficient: an edition replaces the entity, so a
14856        // gate that only reads the NEW position lets an admin at position 5
14857        // rewrite the position-1 role to position 9. Every check passes (9 is
14858        // beneath them), and the role that outranked them — plus everyone
14859        // holding it — is now beneath them.
14860        let (bed, owner, attacker) = TestBed::new();
14861        bed.swap_to(&owner);
14862        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
14863
14864        // A senior role at position 1, and a mid role at position 5 the attacker holds.
14865        let senior = "a1".repeat(32);
14866        let mid = "a5".repeat(32);
14867        publish_role(&bed.relay, &community, &owner.keys,
14868            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
14869        publish_role(&bed.relay, &community, &owner.keys,
14870            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0, extra: Default::default() }, 1).await;
14871        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
14872
14873        // The attacker republishes the SENIOR role, dropping it beneath themselves.
14874        publish_role(&bed.relay, &community, &attacker.keys,
14875            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0, extra: Default::default() }, 2).await;
14876
14877        let authority = fetch_authority(&bed.relay, &community).await;
14878        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
14879        assert_eq!(
14880            folded_senior.position, 1,
14881            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
14882        );
14883    }
14884
14885    #[tokio::test]
14886    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
14887        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
14888        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
14889        // an uncited Vector admin's ban/role/channel edit was silently dropped
14890        // by every Armada client — only the owner's actions crossed. The
14891        // citation must name the actor's OWN grant coordinate, at the version
14892        // and edition hash the verifier can match against a grant it holds.
14893        let (bed, owner, admin) = TestBed::new();
14894        bed.swap_to(&owner);
14895        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
14896        let rid = "c1".repeat(32);
14897        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
14898        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
14899
14900        // The owner's own edition carries NO citation: their rank is the id.
14901        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
14902        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
14903        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
14904            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
14905            .max_by_key(|e| e.version).expect("the owner's metadata edition");
14906        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
14907
14908        // The admin JOINS and folds — the citation names the grant head their own
14909        // client has actually synced, so the fold must have persisted it.
14910        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14911        let bundle_json = serde_json::to_string(&bundle).unwrap();
14912        bed.swap_to(&admin);
14913        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14914        let _ = follow_control(&bed.relay, &joined).await;
14915        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14916        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
14917
14918        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
14919            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
14920            .expect("the admin's banlist edition");
14921        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
14922        assert_eq!(
14923            cite.entity_id,
14924            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
14925            "the citation must name the ACTOR'S OWN grant coordinate",
14926        );
14927        assert!(cite.version >= 1, "pinned to a real grant version");
14928    }
14929
14930    #[tokio::test]
14931    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
14932        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
14933        // fold is a boundary like any other: MANAGE_METADATA makes an editor
14934        // authorized, not trusted. An oversize list costs every member a fan-out
14935        // per publish and the slowest of N per fetch — and Armada caps at 5, so
14936        // an uncapped fold also splits the two clients' operative sets.
14937        let (_tmp, _guard, _owner) = init_test_db();
14938        let relay = MemoryRelay::new();
14939        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
14940
14941        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
14942        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
14943        edit_community_metadata(&relay, &community, &meta).await.unwrap();
14944
14945        let updated = follow_control(&relay, &community).await.unwrap().updated
14946            .expect("the metadata edition is folded");
14947        assert_eq!(
14948            updated.relays.len(),
14949            crate::community::MAX_COMMUNITY_RELAYS,
14950            "a folded relay list must be truncated, never adopted whole",
14951        );
14952
14953        // …and the fold must SETTLE: comparing an oversize edition against the
14954        // capped working set would never be equal, so every later fold would
14955        // report a change and re-save forever.
14956        let again = follow_control(&relay, &updated).await.unwrap().updated;
14957        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
14958    }
14959
14960    #[tokio::test]
14961    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
14962        // One Invite List spans every community, so "I hold links" must never be
14963        // read as "I hold links HERE". A member with links elsewhere adopting a
14964        // rotation would otherwise publish an empty Registry edition into this
14965        // community — a control-plane write and a version bump on a coordinate
14966        // they never owned, every rotation, forever.
14967        let (bed, owner, me) = TestBed::new();
14968        bed.swap_to(&owner);
14969        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
14970        let rid = "b2".repeat(32);
14971        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
14972        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
14973
14974        let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
14975        let bundle_json = serde_json::to_string(&bundle).unwrap();
14976        bed.swap_to(&me);
14977        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
14978        let _ = follow_control(&bed.relay, &joined).await;
14979        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
14980
14981        // My only link lives in a DIFFERENT community — one I own, since minting
14982        // is offer-gated on CREATE_INVITE.
14983        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
14984        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
14985
14986        let before = bed.relay.stored_count();
14987        let new_root = [0xE1; 32];
14988        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
14989        let rotation_events = bed.relay.stored_count() - before;
14990
14991        let after_adopt = bed.relay.stored_count();
14992        follow_rekeys(&bed.relay, &joined, &crate::db::current_session()).await.unwrap();
14993        assert_eq!(
14994            bed.relay.stored_count(),
14995            after_adopt,
14996            "adopting a rotation must publish NOTHING when I minted no links here",
14997        );
14998        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
14999    }
15000
15001    #[tokio::test]
15002    async fn an_expired_link_stops_keeping_the_community_public() {
15003        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
15004        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
15005        // the Registry states a door that isn't there — the aggregate never
15006        // empties and the community reads Public forever, silently inverting
15007        // every gate that hangs off that reading.
15008        let (_tmp, _guard, _owner) = init_test_db();
15009        let relay = MemoryRelay::new();
15010        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
15011
15012        // A link that lapsed a minute ago.
15013        let past = now_ms() - 60_000;
15014        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
15015        assert!(
15016            !community_is_public(&relay, &community).await,
15017            "an already-expired link must never read as a live door",
15018        );
15019
15020        // …and one that hasn't, to prove the filter isn't just dropping everything.
15021        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
15022        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
15023    }
15024
15025    #[tokio::test]
15026    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
15027        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
15028        // link publishes it (Public); retiring the last link empties it (Private).
15029        let (_tmp, _guard, _owner) = init_test_db();
15030        let relay = MemoryRelay::new();
15031        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
15032        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
15033
15034        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15035        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
15036        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
15037        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
15038
15039        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
15040        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
15041        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
15042        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
15043        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
15044    }
15045
15046    #[tokio::test]
15047    async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
15048        // Every caller reads the `invite_registry` COLUMN, never the async fold. v2
15049        // published the Registry to the plane but never mirrored it locally, so every
15050        // v2 community read Private no matter how many live links it had.
15051        let (_tmp, _guard, _owner) = init_test_db();
15052        let relay = MemoryRelay::new();
15053        let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
15054        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
15055        let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
15056        // The per-creator split is a SEPARATE table, and it drives the "first link flips
15057        // the community Public" confirm — an empty one re-asks on every later link.
15058        let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
15059        assert!(cached().is_empty(), "a fresh community caches an empty registry");
15060        assert!(per_creator().is_empty(), "…and no per-creator sets");
15061
15062        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15063        assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
15064        let sets = per_creator();
15065        assert_eq!(sets.len(), 1, "the minting creator gets a set");
15066        assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
15067
15068        // Both caches must SHRINK too — a union-only mirror would strand it Public.
15069        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
15070        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
15071        assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
15072        assert!(per_creator().is_empty(), "…and clears the per-creator sets");
15073    }
15074
15075    #[tokio::test]
15076    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
15077        // Registries are coordinate-bound to their creator, but `fold_head` picks an
15078        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
15079        // that freely by varying content. Folding before authorising would let any
15080        // member occupy the owner's registry head, fail the authority check, and drop
15081        // the whole registry: a live invite link silently retired, flipping the
15082        // community to Private and steering a moderator into the wrong ban remedy.
15083        let (_tmp, _guard, owner) = init_test_db();
15084        let relay = MemoryRelay::new();
15085        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
15086        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
15087        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
15088
15089        let cid = community.id();
15090        let control = control::ControlPlane::of(&community).write_group().unwrap();
15091        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
15092
15093        let query = Query {
15094            kinds: vec![stream::KIND_WRAP],
15095            authors: vec![control.pk_hex()],
15096            limit: Some(FOLLOW_PAGE),
15097            ..Default::default()
15098        };
15099        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
15100        let target = wraps
15101            .iter()
15102            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
15103            .filter(|e| e.entity_id == eid)
15104            .max_by_key(|e| e.version)
15105            .expect("the owner published a registry");
15106
15107        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
15108        // real head on the tiebreak (~2 tries against a uniform id).
15109        let rogue = Keys::generate();
15110        let mut planted = false;
15111        for n in 0..4_000u64 {
15112            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
15113            let rumor = control::build_edition_rumor(
15114                rogue.public_key(),
15115                vsk::INVITE_LINKS,
15116                &eid,
15117                target.version,
15118                target.prev_hash.as_ref(),
15119                &content,
15120                9_000,
15121                None,
15122            );
15123            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
15124            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
15125            if ed.inner_id < target.inner_id {
15126                relay.publish(&w, &community.relays).await.unwrap();
15127                planted = true;
15128                break;
15129            }
15130        }
15131        assert!(planted, "the test needs a fork that wins the tiebreak");
15132
15133        assert!(
15134            community_is_public(&relay, &community).await,
15135            "an unauthorised fork must not retire the owner's live link"
15136        );
15137    }
15138
15139    #[tokio::test]
15140    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
15141        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
15142        let (_tmp, _guard, owner) = init_test_db();
15143        let relay = MemoryRelay::new();
15144        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
15145        let rogue = Keys::generate();
15146        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
15147        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
15148        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
15149        let group = control::ControlPlane::of(&community).write_group().unwrap();
15150        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
15151        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
15152        relay.publish(&wrap, &community.relays).await.unwrap();
15153        let _ = owner;
15154        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
15155    }
15156
15157    #[tokio::test]
15158    async fn full_lifecycle_e2e() {
15159        // The whole stack end to end across two accounts: create -> Public link ->
15160        // owner grants an admin -> member joins + reads history -> admin edits metadata
15161        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
15162        // Refounding) -> the banned member is severed AND stays banned across the new
15163        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
15164        let (bed, owner, member) = TestBed::new();
15165
15166        bed.swap_to(&owner);
15167        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
15168        let general = community.channels[0].id;
15169        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
15170
15171        // Public link → the community reads Public.
15172        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
15173        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
15174
15175        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
15176        let rid = "aa".repeat(32);
15177        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15178        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
15179
15180        // Member joins from the bundle + reads the owner's message.
15181        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
15182        let bundle_json = serde_json::to_string(&bundle).unwrap();
15183        bed.swap_to(&member);
15184        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15185        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
15186        // The admin folds first — adopting the staff write key their Grant
15187        // delivered (CORD-04 §3) — then renames the community.
15188        let _ = follow_control(&bed.relay, &joined).await;
15189        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
15190        assert!(joined.control_root.is_some(), "the Grant's control_wrap was adopted on fold");
15191        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
15192
15193        // Owner follows: the admin's rename folds (authorized).
15194        bed.swap_to(&owner);
15195        let updated = follow_control(&bed.relay, &community).await.unwrap().updated.expect("the admin edit folds");
15196        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
15197
15198        // Ban the member (the three-removal composition, in order).
15199        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
15200        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
15201        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
15202        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
15203        // The ban survives the Refounding (the banlist head compacted forward).
15204        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15205        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
15206        // Pre-ban history still reads across the new epoch.
15207        assert!(
15208            all_texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
15209            "pre-refounding history stays reachable by paging"
15210        );
15211
15212        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
15213        // the swap (the harness swap bumps the generation like production).
15214        bed.swap_to(&member);
15215        let session = crate::db::current_session();
15216        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15217        assert!(follow.self_removed, "the banned member is cryptographically cut");
15218
15219        // Owner dissolves → sealed.
15220        bed.swap_to(&owner);
15221        dissolve_community(&bed.relay, &refounded).await.unwrap();
15222        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
15223    }
15224
15225    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
15226    /// over one shared relay, create → channels (public + private) → converse both ways →
15227    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
15228    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
15229    /// doubles as a reference transcript and a re-runnable regression.
15230    #[tokio::test]
15231    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
15232        // A member forges a higher-version role edition at the admin coordinate before a
15233        // refounding. The compaction must carry the AUTHORIZED floor head, not the
15234        // author-blind version tip — else the forgery is re-anchored, honest folders drop
15235        // it, and the admin role vanishes at the new epoch (silent suppression).
15236        let (bed, owner, member) = TestBed::new();
15237        let attacker = Keys::generate();
15238        bed.swap_to(&owner);
15239        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
15240        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
15241        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
15242        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
15243        // Owner folds → the authorized role/grant heads are floored.
15244        follow_control(&bed.relay, &community).await.unwrap().updated;
15245        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
15246
15247        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
15248        publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0, extra: Default::default() }, 2).await;
15249
15250        // Owner refounds (keeping everyone).
15251        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
15252        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
15253
15254        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
15255        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15256        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
15257    }
15258
15259    #[tokio::test]
15260    async fn memberlist_survives_a_refounding_via_the_snapshot() {
15261        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
15262        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
15263        let (bed, owner, member) = TestBed::new();
15264        bed.swap_to(&owner);
15265        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
15266
15267        // Member joins (a Guestbook Join at epoch 0).
15268        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
15269        bed.swap_to(&member);
15270        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
15271        bed.swap_to(&owner);
15272        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
15273
15274        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
15275        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
15276        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
15277
15278        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
15279        let members = memberlist(&bed.relay, &refounded).await.unwrap();
15280        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
15281        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
15282    }
15283
15284    #[tokio::test]
15285    async fn e2e_two_accounts_channels_converse_moderate() {
15286        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
15287        use nostr_sdk::prelude::ToBech32;
15288        let (bed, a, b) = TestBed::new();
15289        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
15290        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
15291        println!("\n===== Concord v2 deep e2e =====");
15292        println!("[acct] A (owner)  = {a_npub}");
15293        println!("[acct] B (member) = {b_npub}");
15294
15295        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
15296        bed.swap_to(&a);
15297        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
15298        let general = community.channels[0].id;
15299        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
15300
15301        // A PRIVATE channel via the REAL create path: an independent key minted at
15302        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
15303        // then announced (vsk 2) — later carried to B in the join bundle.
15304        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15305        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15306        let priv_ch = community.channel(&priv_id).unwrap();
15307        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
15308        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
15309
15310        // Two more PUBLIC channels via the real create path.
15311        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
15312        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15313        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
15314        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15315        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
15316        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
15317
15318        // A talks in a few channels.
15319        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
15320        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
15321        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
15322        println!("[msg] A posted in #general / #announcements / #mods");
15323
15324        // ── A grants B admin, mints a public link, B joins from the bundle ──
15325        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
15326        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
15327        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
15328        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
15329        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
15330        println!("[invite] granted B @admin · minted link {}", link.url);
15331
15332        // A private channel is readable only by granted role-holders (CORD-03), so
15333        // B is added to its access list before the bundle is minted.
15334        grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
15335        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
15336        bed.swap_to(&b);
15337        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15338        println!("[join] B joined; sees {} channels", b_view.channels.len());
15339        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
15340        assert!(b_view.channels.iter().any(|c| c.id.0 == priv_id.0 && c.private && c.key.is_some()), "B holds the private channel key");
15341        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
15342        assert!(texts_in(&bed.relay, &b_view, &priv_id).await.contains(&"A: mods-only channel".to_string()), "B reads the PRIVATE channel with the bundle key");
15343        // B folds the control plane (persisting the roster) — the live worker does
15344        // this right after any join; B's admin standing gates B's channel ops below.
15345        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
15346            b_view = fresh;
15347        }
15348        println!("[follow] B folded control (roster persisted: B is @admin)");
15349
15350        // ── Conversation both ways + persistence (get_messages-level) ──
15351        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
15352        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
15353        println!("[msg] B replied in #general + #mods");
15354        // Persist B's own #general view into the shared store (what sync/live ingest does)
15355        // and confirm it reads back via STATE — get_messages parity.
15356        let my_pk = b.keys.public_key();
15357        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
15358        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
15359            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
15360            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
15361        }
15362        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
15363        println!("[persist] #general history persisted into the shared events store");
15364
15365        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
15366        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
15367        bed.swap_to(&a);
15368        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
15369        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!", &[]).await.unwrap();
15370        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
15371        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
15372        println!("[ops] reaction + edit + delete round-tripped");
15373
15374        // ── B creates a channel as admin, A folds it in ──
15375        bed.swap_to(&b);
15376        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
15377        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
15378        bed.swap_to(&a);
15379        if let Some(updated) = follow_control(&bed.relay, &community).await.unwrap().updated {
15380            community = updated;
15381        }
15382        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
15383        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
15384
15385        // ── A creates a SECOND private channel while B is already a member. B is
15386        // NOT on its access list, so B learns the channel exists (control-follow,
15387        // keyless) and gets no key: CORD-03's private channel is readable only by
15388        // granted role-holders, never by every member. B keys up if and when A
15389        // grants them the channel's access role and vends the key ──
15390        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15391        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15392        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
15393        println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
15394        bed.swap_to(&b);
15395        let session_b2 = crate::db::current_session();
15396        if let Some(fresh) = follow_control(&bed.relay, &b_view).await.unwrap().updated {
15397            b_view = fresh;
15398        }
15399        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
15400        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
15401        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
15402        if let Some(fresh) = rf.updated {
15403            b_view = fresh;
15404        }
15405        let ch = b_view.channel(&vault).expect("still recorded");
15406        assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
15407        assert!(
15408            texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
15409            "and reads nothing from it"
15410        );
15411        assert!(
15412            send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
15413            "an unentitled member cannot post into the channel either"
15414        );
15415        bed.swap_to(&a);
15416        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15417        println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
15418
15419        // ── Members ──
15420        let members = memberlist(&bed.relay, &community).await.unwrap();
15421        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
15422        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
15423        println!("[members] {} members: A + B present", members.len());
15424
15425        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
15426        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
15427        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
15428        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
15429        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
15430        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15431        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
15432        assert!(all_texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history pages back across the new epoch");
15433        assert!(
15434            all_texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
15435            "PRIVATE history pages back across the channel's own rotation (per-channel multi-epoch archive)"
15436        );
15437        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
15438        // B concludes it's severed.
15439        bed.swap_to(&b);
15440        let session_b3 = crate::db::current_session();
15441        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
15442        println!("[ban] B's rekey-follow: self_removed = true (severed)");
15443
15444        // ── Unban: A lifts the ban ──
15445        bed.swap_to(&a);
15446        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
15447        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
15448        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
15449        println!("[unban] B removed from the banlist (re-invitable)");
15450
15451        // ── Dissolve ──
15452        dissolve_community(&bed.relay, &refounded).await.unwrap();
15453        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
15454        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
15455    }
15456
15457    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
15458    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
15459    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
15460    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
15461    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
15462    #[tokio::test]
15463    #[ignore]
15464    async fn live_e2e_two_accounts() {
15465        use crate::community::transport::LiveTransport;
15466        use nostr_sdk::prelude::ToBech32;
15467
15468        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
15469        let relays = vec![relay.clone()];
15470        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
15471        crate::db::close_database();
15472        crate::db::clear_id_caches();
15473        let tmp = tempfile::tempdir().unwrap();
15474        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
15475
15476        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
15477        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
15478        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
15479
15480        let log = |line: String| {
15481            println!("{line}");
15482            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
15483                use std::io::Write;
15484                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
15485                    let _ = writeln!(f, "{line}");
15486                }
15487            }
15488        };
15489        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
15490        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
15491        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
15492
15493        for k in [&a, &b] {
15494            let npub = k.public_key().to_bech32().unwrap();
15495            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
15496            crate::db::set_current_account(npub.clone()).unwrap();
15497            crate::db::init_database(&npub).unwrap();
15498        }
15499        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
15500        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
15501        let client = crate::nostr_client_builder().build();
15502        client.add_managed_relay(relay.as_str()).await.ok();
15503        client.connect().await;
15504        crate::state::set_nostr_client(client);
15505        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
15506        let become_acct = |k: &Keys| {
15507            let npub = k.public_key().to_bech32().unwrap();
15508            crate::db::set_current_account(npub.clone()).unwrap();
15509            crate::db::init_database(&npub).unwrap();
15510            crate::db::clear_id_caches();
15511            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
15512            crate::state::set_my_public_key(k.public_key());
15513        };
15514        let settle = || crate::rt::time::sleep(std::time::Duration::from_secs(2));
15515
15516        // A: create + a channel + grant B admin + mint link.
15517        become_acct(&a);
15518        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
15519        let general = community.channels[0].id;
15520        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
15521        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
15522        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
15523        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15524        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
15525        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
15526        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
15527        log(format!("[invite] B granted @admin · link {}", link.url));
15528        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
15529        settle().await;
15530
15531        // B: join + read A's history + reply.
15532        become_acct(&b);
15533        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
15534        log(format!("[join] B joined; {} channels", b_view.channels.len()));
15535        settle().await;
15536        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
15537        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15538        log(format!("[read] B sees #general: {seen:?}"));
15539        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
15540        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
15541
15542        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
15543        // sends) directly onto the chat plane — proving the cross-client thread
15544        // RECEIVE path works live, not just in the offline fixture.
15545        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
15546        let hello_id = hello.event.opened().rumor_id.to_hex();
15547        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
15548        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
15549        let reply_rumor = chat::build_comment_rumor(bkeys.public_key(), &general, b_view.root_epoch, "B: threaded reply to hello", &hello_id, super::super::kind::MESSAGE, &a.public_key().to_hex(), None, &[], now_ms());
15550        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
15551        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
15552        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
15553        settle().await;
15554
15555        // A reads the thread reply back, rendered inline with A's message as parent.
15556        become_acct(&a);
15557        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15558        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
15559        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
15560        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
15561            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
15562            assert_eq!(reply_to.as_ref().map(|r| crate::simd::hex::bytes_to_hex_32(&r.id)), Some(hello_id.clone()), "the 1111 renders inline with A's message as parent");
15563        } else {
15564            panic!("the 1111 parsed as a Message");
15565        }
15566        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
15567        become_acct(&b);
15568        settle().await;
15569
15570        // A: create a PRIVATE channel while B is already a member — B is a recipient
15571        // of the creation delivery, so B keys up from the rekey plane over the real
15572        // relay (no bundle involved), then the two converse on it.
15573        become_acct(&a);
15574        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15575        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
15576        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15577        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
15578        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
15579        settle().await;
15580
15581        become_acct(&b);
15582        let session_b = crate::db::current_session();
15583        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
15584        if let Some(fresh) = follow_control(&transport, &b_view).await.expect("B control follow").updated {
15585            b_view = fresh;
15586        }
15587        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
15588            b_view = fresh;
15589        }
15590        let vch = b_view.channel(&vault).expect("B folded the vault");
15591        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
15592        let vseen = texts_in(&transport, &b_view, &vault).await;
15593        log(format!("[read] B sees #vault: {vseen:?}"));
15594        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
15595        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
15596        settle().await;
15597
15598        become_acct(&a);
15599        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15600        assert!(
15601            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
15602            "A reads B's private reply"
15603        );
15604        log("[private] two-way #vault conversation over the live relay".to_string());
15605
15606        // A: ban B (three-removal) + dissolve.
15607        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
15608        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
15609        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
15610        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
15611        settle().await;
15612        dissolve_community(&transport, &refounded).await.expect("dissolve");
15613        log("[dissolve] community sealed".to_string());
15614        log("===== LIVE e2e PASS =====".to_string());
15615    }
15616
15617    #[tokio::test]
15618    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
15619        // The tombstone rides its own public plane, watched live — an OFFLINE
15620        // member's catch-up must fetch it too, or they follow (and post into) a
15621        // grave forever.
15622        let (bed, owner, member) = TestBed::new();
15623        bed.swap_to(&owner);
15624        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
15625        let general = community.channels[0].id;
15626        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
15627
15628        bed.swap_to(&member);
15629        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
15630        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
15631
15632        // The owner dissolves while the member sleeps.
15633        bed.swap_to(&owner);
15634        dissolve_community(&bed.relay, &community).await.unwrap();
15635
15636        // The member's catch-up learns of the death, seals, and refuses to post.
15637        bed.swap_to(&member);
15638        let session = crate::db::current_session();
15639        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15640        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
15641        assert!(!follow.self_removed && follow.updated.is_none());
15642        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
15643        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
15644        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
15645        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
15646        // Subsequent follows take the local fast path — still dissolved, no churn.
15647        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
15648        assert!(again.dissolved && again.updated.is_none());
15649    }
15650
15651    #[tokio::test]
15652    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
15653        // Scale stress: MANY private channels, each rotated on every Refounding.
15654        // A member offline across two refoundings must converge on all of them
15655        // (the per-channel rotation fan in refound + the follow's channel×root×step
15656        // loops stay bounded) with every channel's history readable.
15657        const PRIV_CHANNELS: usize = 6;
15658        let (bed, owner, member) = TestBed::new();
15659        bed.swap_to(&owner);
15660        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
15661        let mut priv_ids = Vec::new();
15662        for i in 0..PRIV_CHANNELS {
15663            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
15664            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15665            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
15666            priv_ids.push(id);
15667        }
15668        // Private channels are readable only by granted role-holders (CORD-03).
15669        for id in &priv_ids {
15670            grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
15671        }
15672        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15673
15674        // Member joins at epoch 0 with all channel keys, then goes offline.
15675        bed.swap_to(&member);
15676        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15677        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
15678
15679        // Two refoundings (each rotates the base + every private channel).
15680        bed.swap_to(&owner);
15681        for epoch in 1..=2u64 {
15682            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15683            assert_eq!(community.root_epoch, Epoch(epoch));
15684            for id in &priv_ids {
15685                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
15686            }
15687        }
15688
15689        // Member returns: bounded follow to quiescence.
15690        bed.swap_to(&member);
15691        let session = crate::db::current_session();
15692        let mut passes = 0;
15693        loop {
15694            passes += 1;
15695            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
15696            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15697            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15698            assert!(!rk.self_removed);
15699            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15700            let ctl = follow_control(&bed.relay, &cur).await.unwrap().updated;
15701            if rk.updated.is_none() && ctl.is_none() {
15702                break;
15703            }
15704        }
15705        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15706        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
15707        // Every private channel converged to the owner's current key + reads all epochs.
15708        for id in &priv_ids {
15709            let mine = caught_up.channel(id).expect("channel survived");
15710            let theirs = community.channel(id).unwrap();
15711            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
15712            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
15713            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
15714            // Opening reads the live planes; the rotated-past ones are the
15715            // back-paging cursor's job. Both halves asserted — the point is that
15716            // every epoch stays REACHABLE, not that opening drags it all in.
15717            let open = texts_in(&bed.relay, &caught_up, id).await;
15718            assert!(open.iter().any(|t| t.contains("epoch2")), "channel {id_hex} opens onto its live epoch");
15719            let paged = all_texts_in(&bed.relay, &caught_up, id).await;
15720            assert!(paged.iter().any(|t| t.contains("epoch0")), "channel {id_hex} pages back to epoch-0 history");
15721            for epoch in 1..=2u64 {
15722                assert!(paged.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} pages back to epoch-{epoch} history");
15723            }
15724        }
15725    }
15726
15727    #[tokio::test]
15728    async fn a_backlog_deeper_than_max_steps_still_converges() {
15729        // MAX_STEPS bounds ONE walk, not the catch-up. A member who slept through more
15730        // rotations than the bound must still land on the head — silently stopping at
15731        // the bound leaves them on a dead epoch reading nothing, which is
15732        // indistinguishable from a quiet community.
15733        let (bed, owner, member) = TestBed::new();
15734        bed.swap_to(&owner);
15735        let mut community = create_community(&bed.relay, "DeepSleeper", bed.relays.clone(), None).await.unwrap();
15736        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15737
15738        bed.swap_to(&member);
15739        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15740        assert_eq!(joined.root_epoch, Epoch(0));
15741
15742        // Past the bound, deliberately.
15743        bed.swap_to(&owner);
15744        let depth = MAX_WALK_STEPS + 3;
15745        for _ in 0..depth {
15746            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15747        }
15748        assert_eq!(community.root_epoch, Epoch(depth as u64));
15749
15750        // The member follows repeatedly, exactly as a client does on each sync. Every
15751        // pass must make forward progress and none may lose ground.
15752        bed.swap_to(&member);
15753        let mut last = 0u64;
15754        for pass in 1..=8 {
15755            // Reload from the DB each pass, exactly as a client does: this is what
15756            // proves every hop was CHECKPOINTED, not merely held in the walk's memory.
15757            let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15758            let session = crate::db::current_session();
15759            let _ = follow_rekeys(&bed.relay, &view, &session).await.unwrap();
15760            let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15761            assert!(
15762                view.root_epoch.0 >= last,
15763                "pass {pass} lost ground: {} -> {}",
15764                last, view.root_epoch.0
15765            );
15766            last = view.root_epoch.0;
15767            if last == depth as u64 {
15768                break;
15769            }
15770        }
15771        let view = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15772        assert_eq!(
15773            view.root_epoch, Epoch(depth as u64),
15774            "a backlog deeper than one walk's step bound must still converge across passes",
15775        );
15776    }
15777
15778    #[tokio::test]
15779    async fn an_offline_member_catches_up_across_three_refoundings() {
15780        // The deep offline-online scenario: a member sleeps through THREE
15781        // Refoundings, per-refound private-channel rotations, a mid-life private
15782        // channel CREATED while they slept, a public channel, a rename, and a
15783        // ban — then returns and converges by follow alone (no rejoin).
15784        use nostr_sdk::prelude::ToBech32;
15785        let (bed, owner, member) = TestBed::new();
15786        bed.swap_to(&owner);
15787        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
15788        let general = community.channels[0].id;
15789        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
15790        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15791        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
15792        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
15793        // Private channels are readable only by granted role-holders (CORD-03).
15794        grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
15795        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
15796
15797        // Member joins at epoch 0, then goes OFFLINE.
15798        bed.swap_to(&member);
15799        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
15800        assert_eq!(member_view.root_epoch, Epoch(0));
15801
15802        // While they sleep, the owner reshapes everything across three epochs.
15803        bed.swap_to(&owner);
15804        let stranger = Keys::generate();
15805        for epoch in 1..=3u64 {
15806            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
15807            assert_eq!(community.root_epoch, Epoch(epoch));
15808            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
15809            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
15810        }
15811        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
15812        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15813        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
15814        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15815        // The sleeper is on this channel's access list, so the refoundings that
15816        // follow deliver its key to them (CORD-03).
15817        grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
15818        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
15819        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
15820        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
15821        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
15822        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
15823
15824        // The member RETURNS: rekey+control follow to quiescence (the worker's
15825        // loop, driven explicitly). Bounded — convergence must be fast.
15826        bed.swap_to(&member);
15827        let session = crate::db::current_session();
15828        let mut passes = 0;
15829        loop {
15830            passes += 1;
15831            assert!(passes <= 6, "catch-up must converge, not churn");
15832            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15833            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
15834            assert!(!rekeyed.self_removed, "the member was never removed");
15835            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15836            let controlled = follow_control(&bed.relay, &cur).await.unwrap().updated;
15837            if rekeyed.updated.is_none() && controlled.is_none() {
15838                break;
15839            }
15840        }
15841        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
15842
15843        // Base + name converged.
15844        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
15845        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
15846        assert_eq!(caught_up.name, "Sleeper Reborn");
15847        // Channels: renamed set incl. the mid-sleep public + private ones.
15848        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
15849        let m = caught_up.channel(&mods).expect("mods survived");
15850        let owner_mods = community.channel(&mods).unwrap();
15851        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
15852        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
15853        let v = caught_up.channel(&vault).expect("vault folded in");
15854        // The sleeper is on vault's access list, but it was created AFTER the last
15855        // refounding — no rotation followed the grant, so no blob was ever
15856        // addressed to them. They hold the channel keyless until the grant's own
15857        // key vend lands (CORD-05 §6), which is what a rekey-only walk cannot do.
15858        assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
15859        // Banlist survived the compactions.
15860        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
15861        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
15862        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
15863        // Opening reads the live planes only; paging back reaches EVERY epoch
15864        // (public via the base-root archive, private via the per-channel archive
15865        // built during the walk). A sleeper's deep history stays reachable — it
15866        // just arrives when they scroll for it rather than on open.
15867        let gen_open = texts_in(&bed.relay, &caught_up, &general).await;
15868        assert!(gen_open.contains(&"epoch3: general news".to_string()), "general opens onto its live epoch: {gen_open:?}");
15869        let gen_texts = all_texts_in(&bed.relay, &caught_up, &general).await;
15870        for epoch in 0..=3u64 {
15871            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
15872            assert!(gen_texts.contains(&needle), "general pages back to epoch {epoch}: {gen_texts:?}");
15873        }
15874        let mods_open = texts_in(&bed.relay, &caught_up, &mods).await;
15875        assert!(mods_open.contains(&"epoch3: mods word".to_string()), "mods opens onto its live epoch: {mods_open:?}");
15876        let mods_texts = all_texts_in(&bed.relay, &caught_up, &mods).await;
15877        for epoch in 0..=3u64 {
15878            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
15879            assert!(mods_texts.contains(&needle), "private pages back to epoch {epoch}: {mods_texts:?}");
15880        }
15881        // Keyless (above) means unreadable — a rekey walk cannot substitute for the
15882        // key vend that a grant carries.
15883        assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
15884        // And the member can still speak.
15885        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
15886        bed.swap_to(&owner);
15887        assert!(
15888            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
15889            "the caught-up member converses at the new epoch ({})",
15890            member.keys.public_key().to_bech32().unwrap()
15891        );
15892    }
15893
15894    /// Seal `n` messages onto a community's #general, one per second starting at
15895    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
15896    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
15897        let general = community.channels[0].id;
15898        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15899        for i in 0..n {
15900            let at = base_secs + i as u64;
15901            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
15902            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
15903            relay.publish(&wrap, &community.relays).await.unwrap();
15904        }
15905    }
15906
15907    #[tokio::test]
15908    async fn the_history_walk_pages_past_a_multi_page_burst() {
15909        // A bot offline through 120 messages must catch ALL of them, not the
15910        // newest page — the v1 sync-gap class, closed by until-paging.
15911        let (_tmp, _guard, owner) = init_test_db();
15912        let relay = MemoryRelay::new();
15913        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
15914        let general = community.channels[0].id;
15915        flood_general(&relay, &community, &owner, 120, 10_000).await;
15916
15917        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15918        assert_eq!(all.len(), 120, "the walk pages the whole burst");
15919        // Oldest→newest, no duplicates.
15920        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15921        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15922        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
15923        let unique: std::collections::HashSet<&String> = contents.iter().collect();
15924        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
15925
15926        // The single-page fetch stays a single page.
15927        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
15928        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
15929        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15930    }
15931
15932    #[tokio::test]
15933    async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
15934        // The back-paging cursor: a walk that starts at an explicit `until`
15935        // returns only what lies at-or-before it, oldest→newest — the relay-side
15936        // half of the SDK's walk-until-dry loop.
15937        let (_tmp, _guard, owner) = init_test_db();
15938        let relay = MemoryRelay::new();
15939        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
15940        let general = community.channels[0].id;
15941        flood_general(&relay, &community, &owner, 120, 10_000).await;
15942
15943        let older = fetch_channel_history(
15944            &relay, &community, &general, 50, 8, None, Some(10_059),
15945            crate::community::transport::Evidence::Quorum, |_| true,
15946        )
15947        .await
15948        .unwrap();
15949        let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
15950        assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
15951        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
15952        assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
15953    }
15954
15955    #[tokio::test]
15956    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
15957        let (_tmp, _guard, owner) = init_test_db();
15958        let relay = MemoryRelay::new();
15959        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
15960        let general = community.channels[0].id;
15961        flood_general(&relay, &community, &owner, 120, 10_000).await;
15962
15963        // The caller says "I hold everything" after the first page — no deeper fetch.
15964        let mut pages = 0usize;
15965        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
15966            pages += 1;
15967            false
15968        })
15969        .await
15970        .unwrap();
15971        assert_eq!(pages, 1, "the early stop is consulted once");
15972        assert_eq!(got.len(), 50, "only the newest page is fetched");
15973        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
15974    }
15975
15976    #[tokio::test]
15977    async fn a_same_second_history_wall_terminates_instead_of_looping() {
15978        // 60 messages in ONE second with a 25-wrap page: a second-granular
15979        // `until` can never page past the wall — the walk must step over it
15980        // (bounded loss, logged) rather than spin.
15981        let (_tmp, _guard, owner) = init_test_db();
15982        let relay = MemoryRelay::new();
15983        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
15984        let general = community.channels[0].id;
15985        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
15986        for i in 0..60usize {
15987            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
15988            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
15989            relay.publish(&wrap, &community.relays).await.unwrap();
15990        }
15991        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
15992        assert!(got.len() >= 25, "at least the relay page is read");
15993        assert!(got.len() <= 60, "sane bound");
15994        // Termination is the assertion: reaching here means the wall didn't loop.
15995    }
15996
15997    #[tokio::test]
15998    async fn a_grant_revoke_survives_a_withholding_relay() {
15999        // Floor persistence on the delegation plane: after the owner revokes an admin,
16000        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
16001        let (_tmp, _guard, owner) = init_test_db();
16002        let relay = MemoryRelay::new();
16003        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
16004        let admin = Keys::generate();
16005        let rid = "d4".repeat(32);
16006        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
16007        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
16008        follow_control(&relay, &community).await.unwrap().updated; // seed floors incl. the grant at v1
16009        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
16010        follow_control(&relay, &community).await.unwrap().updated;
16011
16012        // A stale relay serves only the grant prefix (v1, the live grant).
16013        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16014        let mut stale = community.clone();
16015        stale.relays = vec!["wss://stale".into()];
16016        let floors = load_floors(&community);
16017        let editions = fetch_control(&relay, &stale).await;
16018        let authority = fold_authority(&stale, &editions, &floors);
16019        assert!(
16020            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
16021            "the persisted grant floor refuses the rolled-back (re-granted) view"
16022        );
16023    }
16024
16025    /// Load the current-epoch floors for a community (test mirror of follow_control).
16026    fn load_floors(community: &CommunityV2) -> Floors {
16027        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16028        crate::db::community::get_all_edition_heads_full(&cid_hex)
16029            .unwrap_or_default()
16030            .into_iter()
16031            .filter(|(_, f)| f.0 == community.root_epoch.0)
16032            .map(|(e, f)| (e, (f.1, f.2, f.3)))
16033            .collect()
16034    }
16035
16036    /// Fetch + open every control edition at a community's control plane (test helper).
16037    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
16038        let group = control::ControlPlane::of(&community).write_group().unwrap();
16039        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16040        relay
16041            .fetch(&q, &community.relays)
16042            .await
16043            .unwrap_or_default()
16044            .iter()
16045            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
16046            .collect()
16047    }
16048
16049    #[tokio::test]
16050    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
16051        let (_tmp, _guard, _owner) = init_test_db();
16052        let relay = MemoryRelay::new();
16053        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
16054        // Only the genesis editions exist; folding them reproduces the held view.
16055        assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
16056    }
16057
16058    #[tokio::test]
16059    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
16060        let (_tmp, _guard, owner) = init_test_db();
16061        let relay = MemoryRelay::new();
16062        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
16063        let new_id = ChannelId([0x5a; 32]);
16064        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
16065
16066        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("a new channel changed the view");
16067        assert_eq!(updated.channels.len(), 2);
16068        let added = updated.channel(&new_id).expect("the new channel folded in");
16069        assert_eq!(added.name, "announcements");
16070        assert!(!added.private);
16071        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
16072
16073        // The new channel is now in the realtime author-set (it would be subscribed).
16074        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
16075        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
16076        assert!(authors.contains(&addr), "the added channel joins the live subscription");
16077
16078        // Persisted: a reload sees it too.
16079        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16080        assert!(reloaded.channel(&new_id).is_some());
16081    }
16082
16083    #[tokio::test]
16084    async fn follow_control_renames_the_community_and_an_existing_channel() {
16085        let (_tmp, _guard, owner) = init_test_db();
16086        let relay = MemoryRelay::new();
16087        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
16088        let general = community.channels[0].id;
16089        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
16090        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
16091        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
16092
16093        let updated = follow_control(&relay, &community).await.unwrap().updated.unwrap();
16094        assert_eq!(updated.name, "New Name");
16095        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
16096        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
16097    }
16098
16099    #[tokio::test]
16100    async fn follow_control_deletes_a_channel() {
16101        let (_tmp, _guard, owner) = init_test_db();
16102        let relay = MemoryRelay::new();
16103        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
16104        let extra = ChannelId([0x77; 32]);
16105
16106        // The channel is first added and folded into the held view.
16107        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
16108        let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
16109        assert!(with_extra.channel(&extra).is_some());
16110
16111        // Then it's tombstoned — the delete (higher version) folds the held one back out.
16112        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
16113        let updated = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
16114        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
16115        assert_eq!(updated.channels.len(), 1, "only #general remains");
16116    }
16117
16118    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
16119    /// community's control plane onto a second relay URL — the withholding-relay
16120    /// simulation: everything it serves is genuinely owner-signed, just stale.
16121    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
16122        let group = control::ControlPlane::of(&community).write_group().unwrap();
16123        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16124        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
16125        for w in &wraps {
16126            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
16127                if ed.version <= max_version {
16128                    relay.inject(w, &[stale_relay.to_string()]);
16129                }
16130            }
16131        }
16132    }
16133
16134    #[tokio::test]
16135    async fn a_withholding_relay_cannot_roll_back_a_rename() {
16136        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
16137        // only the (owner-signed) v1 genesis must not revert the held name.
16138        let (_tmp, _guard, owner) = init_test_db();
16139        let relay = MemoryRelay::new();
16140        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
16141        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
16142
16143        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("rename adopted");
16144        assert_eq!(updated.name, "Renamed");
16145
16146        // The stale relay holds only the genesis prefix; point the follow at it.
16147        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16148        let mut stale_view = updated.clone();
16149        stale_view.relays = vec!["wss://stale".into()];
16150        assert!(
16151            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16152            "a stale-only relay must not change the held view"
16153        );
16154        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16155        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
16156    }
16157
16158    #[tokio::test]
16159    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
16160        let (_tmp, _guard, owner) = init_test_db();
16161        let relay = MemoryRelay::new();
16162        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
16163        let extra = ChannelId([0x44; 32]);
16164
16165        // A same-content metadata edit: no visible change (None), but the floor must
16166        // still advance to v2 (so the genesis metadata can't re-present below).
16167        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
16168        assert!(follow_control(&relay, &community).await.unwrap().updated.is_none());
16169
16170        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
16171        let with_extra = follow_control(&relay, &community).await.unwrap().updated.expect("added");
16172        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
16173        let pruned = follow_control(&relay, &with_extra).await.unwrap().updated.expect("removed");
16174        assert!(pruned.channel(&extra).is_none());
16175
16176        // The stale relay serves the add (v1) but withholds the delete (v2).
16177        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
16178        let mut stale_view = pruned.clone();
16179        stale_view.relays = vec!["wss://stale".into()];
16180        assert!(
16181            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16182            "the withheld delete must not resurrect the channel"
16183        );
16184        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16185        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
16186    }
16187
16188    #[tokio::test]
16189    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
16190        // The Armada-convergence carve-out: a Refounding compacts the chain and
16191        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
16192        // floor must not block it — epoch-filtering makes the entity bootstrap.
16193        let (_tmp, _guard, owner) = init_test_db();
16194        let relay = MemoryRelay::new();
16195        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
16196        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
16197        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("edit adopted");
16198        assert_eq!(updated.name, "Edited");
16199
16200        // Refounding lands (epoch bump saved by the rekey path); the compacted head
16201        // arrives DETACHED (high version, no prev) on the new epoch's plane.
16202        let refounded = rotate_view(&updated, updated.community_root, 1);
16203        crate::db::community::save_community_v2(&refounded).unwrap();
16204        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
16205
16206        let adopted = follow_control(&relay, &refounded).await.unwrap().updated.expect("compacted head adopted");
16207        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
16208        // The persisted floor is stamped with the epoch the FOLD ran under.
16209        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16210        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
16211        assert!(
16212            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
16213            "the adopted head carries the fold's epoch + version"
16214        );
16215    }
16216
16217    #[tokio::test]
16218    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
16219        // Two owner-signed editions at the SAME version (publish retry / two owner
16220        // devices): every client must land on the lower-inner-id winner. A hash-strict
16221        // floor would wedge here forever while Armada converges — the floor must
16222        // CONVERGE instead (the v1 decide() rule).
16223        let (_tmp, _guard, owner) = init_test_db();
16224        let relay = MemoryRelay::new();
16225        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
16226        let group = control::ControlPlane::of(&community).write_group().unwrap();
16227        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16228
16229        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
16230        let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
16231        assert_eq!(ours.name, "Ours");
16232
16233        // Our committed v2 edition's tiebreak id.
16234        let our_inner = {
16235            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
16236            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
16237            wraps
16238                .iter()
16239                .find_map(|w| {
16240                    control::open_control_edition(w, &group)
16241                        .ok()
16242                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
16243                        .map(|(ed, _)| ed.inner_id)
16244                })
16245                .unwrap()
16246        };
16247
16248        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
16249        // authored timestamp until its inner id is lower).
16250        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
16251        let content = serde_json::to_string(&meta).unwrap();
16252        let mut ts = 2_000u64;
16253        let fork_wrap = loop {
16254            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
16255            let inner = rumor.id.unwrap().to_bytes();
16256            if inner < our_inner {
16257                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
16258            }
16259            ts += 1;
16260        };
16261        relay.publish(&fork_wrap, &community.relays).await.unwrap();
16262
16263        let converged = follow_control(&relay, &ours).await.unwrap().updated.expect("fork winner adopted");
16264        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
16265        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16266        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
16267        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
16268    }
16269
16270    #[tokio::test]
16271    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
16272        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
16273        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
16274        // while the detached v4 waits. When v3 lands, the chain heals to v4.
16275        let (_tmp, _guard, owner) = init_test_db();
16276        let relay = MemoryRelay::new();
16277        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
16278        let group = control::ControlPlane::of(&community).write_group().unwrap();
16279
16280        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
16281        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16282
16283        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
16284        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
16285        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
16286        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
16287        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
16288        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
16289        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
16290        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
16291        relay.publish(&w4, &community.relays).await.unwrap();
16292
16293        let updated = follow_control(&relay, &community).await.unwrap().updated.expect("the verified prefix applies");
16294        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
16295
16296        relay.publish(&w3, &community.relays).await.unwrap();
16297        let healed = follow_control(&relay, &updated).await.unwrap().updated.expect("the chain heals");
16298        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
16299    }
16300
16301    #[tokio::test]
16302    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
16303        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
16304        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
16305        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
16306        let (_tmp, _guard, owner) = init_test_db();
16307        let relay = MemoryRelay::new();
16308        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
16309        let group = control::ControlPlane::of(&community).write_group().unwrap();
16310
16311        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
16312        let base = follow_control(&relay, &community).await.unwrap().updated.expect("floor at v2");
16313        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
16314        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16315
16316        // Rogue flood occupying the newest window (sealed to the control plane, but
16317        // non-owner — the authority gate drops them; they only crowd the page).
16318        let rogue = Keys::generate();
16319        for i in 0..(FOLLOW_PAGE as u64 - 1) {
16320            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
16321            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
16322            relay.publish(&w, &community.relays).await.unwrap();
16323        }
16324        // v4 chained to the real v3 (crafted directly: the flood also blinds the
16325        // helper's own newest-window head lookup), timestamped newest of all.
16326        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
16327        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
16328        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
16329        relay.publish(&w4, &community.relays).await.unwrap();
16330
16331        let healed = follow_control(&relay, &base).await.unwrap().updated.expect("paging recovered the chain");
16332        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
16333    }
16334
16335    #[tokio::test]
16336    async fn a_follow_after_delete_does_not_resurrect_the_community() {
16337        // A leave/delete racing an in-flight follow: the follow must not re-insert
16338        // the community row or floor rows past delete_community's wipe.
16339        let (_tmp, _guard, owner) = init_test_db();
16340        let relay = MemoryRelay::new();
16341        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
16342        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
16343        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16344        crate::db::community::delete_community(&cid_hex).unwrap();
16345
16346        assert!(
16347            follow_control(&relay, &community).await.unwrap().updated.is_none(),
16348            "a follow racing a delete is a no-op"
16349        );
16350        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
16351        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
16352    }
16353
16354    #[tokio::test]
16355    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
16356        // The rekey sibling of the follow_control guard: an owner rotation adopted
16357        // mid-race must not upsert the community row back after a leave/delete.
16358        let (_tmp, _guard, owner) = init_test_db();
16359        let relay = MemoryRelay::new();
16360        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
16361        let new_root = [0xB2; 32];
16362        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
16363        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16364        crate::db::community::delete_community(&cid_hex).unwrap();
16365
16366        let session = crate::db::current_session();
16367        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
16368        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
16369        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
16370    }
16371
16372    #[tokio::test]
16373    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
16374        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
16375        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
16376        // would take v1 and SEED the joiner's floor there — pinning them below the
16377        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
16378        let (bed, owner, member) = TestBed::new();
16379        bed.swap_to(&owner);
16380        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
16381        let group = control::ControlPlane::of(&community).write_group().unwrap();
16382        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
16383
16384        // v2 is crafted but NEVER published; v3 chains to it and is published.
16385        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
16386        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
16387        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
16388        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
16389        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
16390        let r3 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&ed2.self_hash), &c3, 3_000, None);
16391        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
16392        bed.relay.publish(&w3, &community.relays).await.unwrap();
16393
16394        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16395        let bundle_json = serde_json::to_string(&bundle).unwrap();
16396        bed.swap_to(&member);
16397        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16398        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
16399        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
16400        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
16401        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
16402    }
16403
16404    #[tokio::test]
16405    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
16406        // The refusal half of fork convergence: a relay withholding OUR committed
16407        // floor edition while serving only a same-version fork with a HIGHER inner
16408        // id must be treated as withholding — held state and floor unchanged.
16409        let (_tmp, _guard, owner) = init_test_db();
16410        let relay = MemoryRelay::new();
16411        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
16412        let group = control::ControlPlane::of(&community).write_group().unwrap();
16413        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
16414
16415        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
16416        let ours = follow_control(&relay, &community).await.unwrap().updated.expect("ours adopted");
16417        assert_eq!(ours.name, "Ours");
16418        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16419        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
16420        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
16421
16422        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
16423        // with the genesis but WITHOUT our v2 — from a withholding relay.
16424        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
16425        let content = serde_json::to_string(&meta).unwrap();
16426        let mut ts = 5_000u64;
16427        let fork_wrap = loop {
16428            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
16429            if rumor.id.unwrap().to_bytes() > our_inner {
16430                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
16431            }
16432            ts += 1;
16433        };
16434        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
16435        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
16436        let mut stale_view = ours.clone();
16437        stale_view.relays = vec!["wss://stale".into()];
16438
16439        assert!(
16440            follow_control(&relay, &stale_view).await.unwrap().updated.is_none(),
16441            "a losing fork served without our floor edition changes nothing"
16442        );
16443        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
16444        assert_eq!(held_after, held_before, "the floor row is untouched");
16445        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16446        assert_eq!(held.name, "Ours", "the held state is untouched");
16447    }
16448
16449    #[tokio::test]
16450    async fn follow_control_ignores_a_non_owner_edition() {
16451        // A member holds the community_root, so they CAN seal a control edition —
16452        // but they aren't the owner, so the authority gate drops it (first cut:
16453        // owner-only). The rogue channel must never appear.
16454        let (_tmp, _guard, _owner) = init_test_db();
16455        let relay = MemoryRelay::new();
16456        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
16457        let rogue = Keys::generate();
16458        let rogue_id = ChannelId([0x99; 32]);
16459        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
16460
16461        assert!(
16462            follow_control(&relay, &community).await.unwrap().updated.is_none(),
16463            "a non-owner control edition is not folded"
16464        );
16465    }
16466
16467    #[tokio::test]
16468    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
16469        // A Private channel's key rides the rekey plane, not the control edition —
16470        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
16471        // every read/send path refuses it until the key lands (never the root plane).
16472        let (_tmp, _guard, owner) = init_test_db();
16473        let relay = MemoryRelay::new();
16474        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
16475        let priv_id = ChannelId([0x33; 32]);
16476        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
16477
16478        let updated = follow_control(&relay, &community)
16479            .await
16480            .unwrap()
16481            .updated
16482            .expect("the keyless record is a change");
16483        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
16484        assert!(ch.private && ch.key.is_none(), "recorded keyless");
16485        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
16486        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
16487        assert!(
16488            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
16489            "a keyless fetch returns empty (and never queries the root plane)"
16490        );
16491        assert!(
16492            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
16493            "a keyless send refuses"
16494        );
16495        // The keyless record round-trips (the stored placeholder never surfaces
16496        // as a real key).
16497        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
16498        let rch = reloaded.channel(&priv_id).unwrap();
16499        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
16500        // And a bundle minted while keyless never carries the placeholder — a
16501        // MEMBER audience, so it's the keyless filter proving it (the link
16502        // filter would drop the channel for the weaker reason).
16503        let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
16504        assert!(
16505            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
16506            "an ungrantable keyless channel stays out of invite bundles"
16507        );
16508    }
16509
16510    #[tokio::test]
16511    async fn a_link_bundle_never_carries_a_private_channel_key() {
16512        // A link's audience holds no Role by construction (CORD-05), so a HELD
16513        // private key must never ride a link bundle — anyone with the URL would
16514        // get the channel. A member bundle carries it; a link bundle only the
16515        // public channels.
16516        let (_tmp, _guard, _owner) = init_test_db();
16517        let relay = MemoryRelay::new();
16518        let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
16519        create_private_channel(&relay, &community, "mods").await.unwrap();
16520        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16521        let priv_hex = held
16522            .channels
16523            .iter()
16524            .find(|c| c.private)
16525            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
16526            .expect("the private channel is held WITH its key");
16527
16528        let link = bundle_of(&held, BundleAudience::Link, None, None, None);
16529        assert!(
16530            !link.channels.iter().any(|c| c.id == priv_hex),
16531            "a held private key must never ride a link bundle"
16532        );
16533        assert!(
16534            link.channels.iter().any(|c| c.id != priv_hex),
16535            "the public channels still ride it"
16536        );
16537
16538        // A member bundle grants it only to the ENTITLED. An unrelated npub holds
16539        // no scoped role, so it gets nothing; the creator (granted the companion
16540        // access role at create) gets the key.
16541        let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
16542        assert!(
16543            !stranger.channels.iter().any(|c| c.id == priv_hex),
16544            "an unentitled member gets no private key"
16545        );
16546        let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
16547        assert!(
16548            mine.channels.iter().any(|c| c.id == priv_hex),
16549            "the creator is entitled via the companion access role"
16550        );
16551    }
16552
16553    /// Mint a server-scope role at `position` carrying `permissions`, grant it to
16554    /// `member`, and return its id. Used to build a RANKED roster (the owner-only
16555    /// tests can't exercise outranking, since the owner outranks everyone).
16556    async fn seat_ranked_role(
16557        relay: &MemoryRelay,
16558        community: &CommunityV2,
16559        member: &PublicKey,
16560        name: &str,
16561        position: u32,
16562        permissions: crate::community::roles::Permissions,
16563    ) -> String {
16564        let role_id = crate::crypto::sha256_hex(format!("test/role/{name}/{position}").as_bytes());
16565        let role = crate::community::roles::Role {
16566            role_id: role_id.clone(),
16567            name: name.to_string(),
16568            position,
16569            permissions,
16570            scope: crate::community::roles::RoleScope::Server,
16571            color: 0, extra: Default::default(),
16572        };
16573        set_role(relay, community, &role).await.unwrap();
16574        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16575        let mut ids: Vec<String> = {
16576            let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16577            let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16578            roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect()
16579        };
16580        if !ids.contains(&role_id) {
16581            ids.push(role_id.clone());
16582        }
16583        grant_roles(relay, &held, member, ids).await.unwrap();
16584        role_id
16585    }
16586
16587    /// Become `who` without touching the account DB — the acting identity only.
16588    /// Enough to exercise an offer-side authority gate from a non-owner's seat.
16589    fn act_as(who: &Keys) {
16590        crate::state::MY_SECRET_KEY.store_from_keys(who, &[]);
16591        crate::state::set_my_public_key(who.public_key());
16592    }
16593
16594    #[tokio::test]
16595    async fn a_moderator_cannot_revoke_channel_access_from_a_superior_and_the_channel_never_rotates() {
16596        // CORD-04 §2: the fold drops a Grant aimed at a peer or superior, but the
16597        // channel rotation is gated on the citation alone — so without an
16598        // offer-side rank check the Grant dies and the target is severed anyway.
16599        // The epoch assertion is the point: refusing the publish is worthless if
16600        // the rekey still runs.
16601        let (_tmp, _guard, owner) = init_test_db();
16602        let relay = MemoryRelay::new();
16603        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16604        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16605
16606        let superior = Keys::generate();
16607        let moderator = Keys::generate();
16608        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16609        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16610        seat_ranked_role(
16611            &relay,
16612            &community,
16613            &moderator.public_key(),
16614            "Moderator",
16615            5,
16616            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16617        )
16618        .await;
16619
16620        // The superior legitimately holds the channel (granted by the owner).
16621        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16622        grant_channel_access(&relay, &held, &vault, &superior.public_key()).await.unwrap();
16623        let before = crate::db::community::load_community_v2(community.id())
16624            .unwrap()
16625            .unwrap()
16626            .channel(&vault)
16627            .unwrap()
16628            .epoch;
16629
16630        act_as(&moderator);
16631        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16632        let err = revoke_channel_access(&relay, &held, &vault, &superior.public_key())
16633            .await
16634            .expect_err("a moderator must not act on an admin");
16635        assert!(err.contains("outrank"), "refused for RANK, not some incidental reason: {err}");
16636
16637        let after = crate::db::community::load_community_v2(community.id())
16638            .unwrap()
16639            .unwrap()
16640            .channel(&vault)
16641            .unwrap()
16642            .epoch;
16643        assert_eq!(after, before, "the refusal must also stop the rotation — the rekey IS the severance");
16644        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16645        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16646        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16647        let owner_hex = owner.public_key().to_hex();
16648        assert!(
16649            roster.is_entitled(Some(&owner_hex), &superior.public_key().to_hex(), &chan_hex, &[], &[]),
16650            "and the superior keeps their access role"
16651        );
16652    }
16653
16654    #[tokio::test]
16655    async fn a_moderator_cannot_grant_channel_access_to_a_superior_but_the_owner_stays_grantable() {
16656        // The grant direction of the same gate. The owner is never a valid rank
16657        // target, yet IS a legitimate recipient of a key an admin minted — the
16658        // rank check must not swallow that case.
16659        let (_tmp, _guard, owner) = init_test_db();
16660        let relay = MemoryRelay::new();
16661        let community = create_community(&relay, "Ranked", vec!["wss://r".into()], None).await.unwrap();
16662        let vault = create_private_channel(&relay, &community, "vault").await.unwrap();
16663
16664        let superior = Keys::generate();
16665        let moderator = Keys::generate();
16666        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16667        grant_admin(&relay, &held, &superior.public_key()).await.unwrap();
16668        let chan_hex = crate::simd::hex::bytes_to_hex_32(&vault.0);
16669        // Scoped access so the moderator holds the channel key itself, else the
16670        // refusal could come from "we hold no key" and prove nothing about rank.
16671        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16672        grant_channel_access(&relay, &held, &vault, &moderator.public_key()).await.unwrap();
16673        seat_ranked_role(
16674            &relay,
16675            &community,
16676            &moderator.public_key(),
16677            "Moderator",
16678            5,
16679            crate::community::roles::Permissions(crate::community::roles::Permissions::MANAGE_ROLES),
16680        )
16681        .await;
16682
16683        act_as(&moderator);
16684        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16685        let err = grant_channel_access(&relay, &held, &vault, &superior.public_key())
16686            .await
16687            .expect_err("a moderator must not grant against an admin");
16688        assert!(err.contains("outrank"), "refused for RANK: {err}");
16689
16690        // Same seat, same channel, owner as the target: allowed.
16691        grant_channel_access(&relay, &held, &vault, &owner.public_key())
16692            .await
16693            .expect("the owner is not a rank target, and may still be handed a key");
16694        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16695        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16696        let owner_hex = owner.public_key().to_hex();
16697        assert!(
16698            roster.is_entitled(Some(&owner_hex), &owner_hex, &chan_hex, &[], &[]),
16699            "the owner's access role landed"
16700        );
16701    }
16702
16703    #[tokio::test]
16704    async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
16705        // CORD-03/04: the roles scoped to a channel ARE its access list. Proven
16706        // against a NON-owner so the owner-is-always-entitled rule can't carry it.
16707        let (_tmp, _guard, _owner) = init_test_db();
16708        let relay = MemoryRelay::new();
16709        let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
16710        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
16711        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16712        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16713
16714        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16715        let access = roster.channel_roles(&chan_hex);
16716        assert_eq!(access.len(), 1, "the channel minted exactly one access role");
16717        assert!(
16718            access[0].permissions == crate::community::roles::Permissions::empty(),
16719            "the access role confers READ access (key possession), never authority"
16720        );
16721        assert_eq!(access[0].name, "mods", "named for its channel");
16722
16723        // A stranger holds no scoped role: unentitled, and no key rides their bundle.
16724        let stranger = Keys::generate().public_key();
16725        let owner_hex = community.owner().unwrap().to_hex();
16726        assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
16727
16728        // Granting the access role entitles them; revoking un-entitles them. Both
16729        // proven through the roster, which is what routes keys.
16730        let role_id = access[0].role_id.clone();
16731        assert!(
16732            roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
16733            "the grant overlay entitles before the fold catches up"
16734        );
16735
16736        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16737        grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16738        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16739        assert!(
16740            after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16741            "the grant landed in the local roster (the fold runs later)"
16742        );
16743        let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
16744        assert!(
16745            vend.channels.iter().any(|c| c.id == chan_hex),
16746            "a now-entitled member's bundle carries the channel key"
16747        );
16748
16749        revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
16750        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
16751        assert!(
16752            !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
16753            "the revoke dropped the access role"
16754        );
16755        let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16756        assert_eq!(
16757            rotated.channel(&priv_id).unwrap().epoch,
16758            Epoch(2),
16759            "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
16760        );
16761
16762        // The access summary a bot reads back: roles, holders, and key state.
16763        let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
16764        assert_eq!(access["private"], true);
16765        assert_eq!(access["readable"], true, "we minted it, so we hold its key");
16766        assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
16767        let holders = access["members"].as_array().unwrap();
16768        let me_npub = {
16769            use nostr_sdk::prelude::ToBech32;
16770            me_pk().unwrap().to_bech32().unwrap()
16771        };
16772        assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
16773        assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
16774    }
16775
16776    #[tokio::test]
16777    async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
16778        // CORD-06 §1 per channel: a Public-community ban skips the Refounding
16779        // (CORD-05 §5), so without this rotation the banned member keeps each
16780        // held channel key and reads on forever. Only channels they could
16781        // actually reach rotate — the rest keep their epoch.
16782        let (_tmp, _guard, _owner) = init_test_db();
16783        let relay = MemoryRelay::new();
16784        let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
16785        let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
16786        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16787        let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
16788        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16789
16790        let spammer = Keys::generate().public_key();
16791        let bystander = Keys::generate().public_key();
16792        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16793        grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
16794        grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
16795
16796        // The ban composition's capture-then-strip: entitlement must be judged
16797        // from the PRE-strip roles, whether or not the strip has folded.
16798        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
16799        let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
16800        assert!(!stripped.is_empty(), "the grant landed before the strip");
16801        grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
16802
16803        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16804        let rotated = sever_banned_private_reads(&relay, &held, &[(spammer, stripped)]).await.unwrap();
16805        assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
16806        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16807        assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
16808        assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
16809
16810        // A member who never had reach rotates nothing.
16811        let stranger = Keys::generate().public_key();
16812        let n = sever_banned_private_reads(&relay, &after, &[(stranger, Vec::new())]).await.unwrap();
16813        assert_eq!(n, 0, "no entitlement, no rotation");
16814    }
16815
16816    #[tokio::test]
16817    async fn ban_severance_refuses_without_manage_channels() {
16818        // Offer-side mirror of `channel_rotator_ok`: readers honor a channel
16819        // rotation only from MANAGE_CHANNELS holders, so a BAN-only moderator
16820        // publishing one would adopt an epoch every reader rejects — a fork.
16821        let (bed, owner, moderator) = TestBed::new();
16822        bed.swap_to(&owner);
16823        let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
16824        create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16825        let rid = "c1".repeat(32);
16826        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16827        publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
16828        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16829        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16830        let bundle_json = serde_json::to_string(&bundle).unwrap();
16831
16832        bed.swap_to(&moderator);
16833        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16834        let _ = follow_control(&bed.relay, &joined).await;
16835        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16836        let target = Keys::generate().public_key();
16837        let err = sever_banned_private_reads(&bed.relay, &joined, &[(target, Vec::new())]).await.unwrap_err();
16838        assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
16839    }
16840
16841    #[tokio::test]
16842    async fn set_banlist_refuses_an_author_the_fold_would_reject() {
16843        // The reader gates the banlist head on BAN and each added entry on strict
16844        // outrank. Publishing anyway would be silently void everywhere while the
16845        // author's own echo caches the phantom — fail-closed at the offer instead.
16846        let (bed, owner, member) = TestBed::new();
16847        bed.swap_to(&owner);
16848        let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
16849        let rid = "c2".repeat(32);
16850        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
16851        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16852        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16853        let bundle_json = serde_json::to_string(&bundle).unwrap();
16854
16855        // A roleless member holds no BAN: refused before any publish.
16856        bed.swap_to(&member);
16857        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16858        let _ = follow_control(&bed.relay, &joined).await;
16859        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16860        let target = Keys::generate().public_key().to_hex();
16861        let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
16862        assert!(err.contains("BAN"), "refused for the missing bit: {err}");
16863        assert!(
16864            crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
16865            "no phantom echo cached"
16866        );
16867
16868        // Grant them BAN: adding a peer they don't outrank still refuses (the
16869        // fold drops per-target on outrank), while an unranked target passes.
16870        bed.swap_to(&owner);
16871        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
16872        let peer = Keys::generate().public_key();
16873        publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
16874        bed.swap_to(&member);
16875        let _ = follow_control(&bed.relay, &joined).await;
16876        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16877        let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
16878        assert!(err.contains("outrank"), "an equal is not actionable: {err}");
16879        set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
16880    }
16881
16882    #[tokio::test]
16883    async fn metadata_and_link_minting_refuse_unauthorized_authors() {
16884        // Both are reader-gated (MANAGE_METADATA; CREATE_INVITE on the registry) —
16885        // the offer must mirror or the SDK reports success on a void publish.
16886        let (bed, owner, member) = TestBed::new();
16887        bed.swap_to(&owner);
16888        let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
16889        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16890        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
16891        let bundle_json = serde_json::to_string(&bundle).unwrap();
16892
16893        bed.swap_to(&member);
16894        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
16895        let _ = follow_control(&bed.relay, &joined).await;
16896        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
16897        let meta = control::CommunityMetadata {
16898            name: "Hijacked".into(),
16899            description: None,
16900            relays: vec![],
16901            icon: None,
16902            banner: None,
16903            custom: None,
16904            extra: Default::default(),
16905        };
16906        assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
16907        assert!(
16908            mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
16909            "link minting refused"
16910        );
16911    }
16912
16913    #[tokio::test]
16914    async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
16915        // JSKitty's race: the vend can land BEFORE the control fold that proves
16916        // the grant. It must park quietly (a lagging fold is not an anomaly) and
16917        // be adopted on the re-judge once the roster catches up.
16918        let (bed, owner, member) = TestBed::new();
16919        bed.swap_to(&owner);
16920        let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
16921        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
16922        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
16923        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
16924        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16925        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
16926        let owner_hex = community.owner().unwrap().to_hex();
16927
16928        // Judge as the MEMBER — the owner is always entitled, so only a non-owner
16929        // can exercise the grant rule at all.
16930        bed.swap_to(&member);
16931        let me = member.keys.public_key().to_hex();
16932        // Their fold has the channel (control-follow records it keyless) but not
16933        // yet the grant that entitles them.
16934        let mut member_view = held.clone();
16935        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16936            c.key = None;
16937            c.epoch = Epoch(0);
16938        }
16939
16940        // Ungranted → PARK, never refuse: this is exactly the "not synced enough
16941        // to judge" case, and it must stay quiet and retryable.
16942        let empty = crate::community::roles::CommunityRoles::default();
16943        assert!(matches!(
16944            judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
16945            VendVerdict::Park(_)
16946        ));
16947
16948        // A channel our fold says is PUBLIC never heals — that's a spoof shape.
16949        let mut public_view = member_view.clone();
16950        if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
16951            c.private = false;
16952        }
16953        assert!(matches!(
16954            judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
16955            VendVerdict::Refuse(_)
16956        ));
16957
16958        // An unknown channel parks (our fold may simply be behind), never refuses.
16959        assert!(matches!(
16960            judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
16961            VendVerdict::Park(_)
16962        ));
16963
16964        // Park the vend, then re-judge with a roster that still lacks our grant:
16965        // it must SURVIVE, not be discarded.
16966        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
16967        crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
16968        crate::db::community::save_community_v2(&member_view).unwrap();
16969        let session = crate::db::current_session();
16970        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16971        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
16972        assert_eq!(
16973            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
16974            1,
16975            "and stays parked for the next fold"
16976        );
16977
16978        // The fold catches up: our grant lands, so the same vend now adopts.
16979        let access = crate::community::roles::Role {
16980            role_id: "44".repeat(32),
16981            name: "mods".into(),
16982            position: u32::MAX - 1,
16983            permissions: crate::community::roles::Permissions::empty(),
16984            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
16985            color: 0, extra: Default::default(),
16986        };
16987        let folded = crate::community::roles::CommunityRoles {
16988            grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
16989            roles: vec![access],
16990        };
16991        crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
16992        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16993        let adopted = absorb_parked_channel_keys(&reloaded, &session);
16994        assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
16995
16996        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
16997        let ch = after.channel(&priv_id).unwrap();
16998        assert_eq!(ch.key, Some(real_key), "adopted the vended key");
16999        assert_eq!(ch.epoch, Epoch(1));
17000        assert!(
17001            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17002            "and the park is discharged"
17003        );
17004    }
17005
17006    #[tokio::test]
17007    async fn a_vend_from_an_unentitled_sender_is_never_seated_but_survives_for_the_real_one() {
17008        // Vend rule 4: the VENDOR must be entitled too. Every other test sends as
17009        // the owner, which short-circuits the check one line above it, so the rule
17010        // itself has never been exercised. Both halves matter — a stranger must not
17011        // seat a key, and their row must not consume the slot either, which is the
17012        // suppression the candidate table exists to prevent.
17013        let (bed, owner, member) = TestBed::new();
17014        bed.swap_to(&owner);
17015        let community = create_community(&bed.relay, "Vendors", bed.relays.clone(), None).await.unwrap();
17016        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17017        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17018        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17019        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17020        let owner_hex = community.owner().unwrap().to_hex();
17021
17022        bed.swap_to(&member);
17023        let mut member_view = held.clone();
17024        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17025            c.key = None;
17026            c.epoch = Epoch(0);
17027        }
17028        crate::db::community::save_community_v2(&member_view).unwrap();
17029
17030        // WE are entitled (rule 3 passes, so the judge reaches rule 4). The vendor
17031        // below is not, and is not the owner.
17032        let access = crate::community::roles::Role {
17033            role_id: "66".repeat(32),
17034            name: "mods".into(),
17035            position: u32::MAX - 1,
17036            permissions: crate::community::roles::Permissions::empty(),
17037            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17038            color: 0, extra: Default::default(),
17039        };
17040        let roster = crate::community::roles::CommunityRoles {
17041            grants: vec![crate::community::roles::MemberGrant {
17042                member: member.keys.public_key().to_hex(),
17043                role_ids: vec![access.role_id.clone()],
17044            }],
17045            roles: vec![access],
17046        };
17047        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17048
17049        let stranger_hex = Keys::generate().public_key().to_hex();
17050        let junk = [0x11; 32];
17051        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &junk, &stranger_hex).unwrap();
17052
17053        let session = crate::db::current_session();
17054        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17055        assert!(
17056            absorb_parked_channel_keys(&reloaded, &session).is_empty(),
17057            "an unentitled vendor seats nothing"
17058        );
17059        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17060        assert_eq!(after.channel(&priv_id).unwrap().key, None, "the channel is still keyless");
17061        assert_eq!(
17062            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
17063            1,
17064            "parked, not refused — the vendor's own entitlement may simply not have folded here yet"
17065        );
17066
17067        // The entitled vendor's key lands despite the stranger's row sitting there.
17068        let real = [0x5a; 32];
17069        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &real, &owner_hex).unwrap();
17070        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17071        assert_eq!(
17072            absorb_parked_channel_keys(&reloaded, &session).len(),
17073            1,
17074            "the genuine vend is seated — a stranger cannot occupy the slot"
17075        );
17076        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17077        assert_eq!(after.channel(&priv_id).unwrap().key, Some(real), "and it is the ENTITLED vendor's key");
17078        assert!(
17079            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17080            "both candidates discharge once one is seated"
17081        );
17082    }
17083
17084    #[tokio::test]
17085    async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
17086        // Live cross-client finding: a peer that mints born-private channels at
17087        // epoch 0 vends epoch 0, which collides with our keyless cursor (also 0).
17088        // The monotonic guard (`new > current`) would refuse the only key we are
17089        // ever offered, and refuse it SILENTLY. First delivery is not a rotation.
17090        let (bed, owner, member) = TestBed::new();
17091        bed.swap_to(&owner);
17092        let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
17093        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17094        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17095        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17096        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17097        let vended = [0x5a; 32];
17098        let owner_hex = community.owner().unwrap().to_hex();
17099
17100        bed.swap_to(&member);
17101        // The member's view: channel known, keyless, parked at the epoch-0 cursor.
17102        let mut member_view = held.clone();
17103        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17104            c.key = None;
17105            c.epoch = Epoch(0);
17106        }
17107        crate::db::community::save_community_v2(&member_view).unwrap();
17108
17109        // Entitle them, then park a vend AT EPOCH 0 (what the peer actually sends).
17110        let access = crate::community::roles::Role {
17111            role_id: "77".repeat(32),
17112            name: "mods".into(),
17113            position: u32::MAX - 1,
17114            permissions: crate::community::roles::Permissions::empty(),
17115            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17116            color: 0, extra: Default::default(),
17117        };
17118        let roster = crate::community::roles::CommunityRoles {
17119            grants: vec![crate::community::roles::MemberGrant {
17120                member: member.keys.public_key().to_hex(),
17121                role_ids: vec![access.role_id.clone()],
17122            }],
17123            roles: vec![access],
17124        };
17125        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17126        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
17127
17128        let session = crate::db::current_session();
17129        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17130        let adopted = absorb_parked_channel_keys(&reloaded, &session);
17131        assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
17132
17133        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17134        let ch = after.channel(&priv_id).unwrap();
17135        assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
17136        assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
17137        assert!(
17138            !after.channel_read_coords(ch).is_empty(),
17139            "and the channel is readable — the whole point"
17140        );
17141        assert!(
17142            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17143            "the park is discharged"
17144        );
17145    }
17146
17147    #[tokio::test]
17148    async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
17149        // The channel head is MONOTONIC, so over-advancing it can never be walked
17150        // back: every genuine rotation afterwards lands at head+1, reads as stale,
17151        // and the channel dies for us with no heal path at all. An entitled
17152        // insider vending a garbage key costs isolation (accepted); one vending a
17153        // garbage EPOCH would cost the channel permanently, which is not.
17154        let (bed, owner, member) = TestBed::new();
17155        bed.swap_to(&owner);
17156        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
17157        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17158        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17159        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17160        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17161        let owner_hex = community.owner().unwrap().to_hex();
17162
17163        bed.swap_to(&member);
17164        let mut member_view = held.clone();
17165        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17166            c.key = None;
17167            c.epoch = Epoch(0);
17168        }
17169        crate::db::community::save_community_v2(&member_view).unwrap();
17170        let access = crate::community::roles::Role {
17171            role_id: "99".repeat(32),
17172            name: "mods".into(),
17173            position: u32::MAX - 1,
17174            permissions: crate::community::roles::Permissions::empty(),
17175            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17176            color: 0, extra: Default::default(),
17177        };
17178        let roster = crate::community::roles::CommunityRoles {
17179            grants: vec![crate::community::roles::MemberGrant {
17180                member: member.keys.public_key().to_hex(),
17181                role_ids: vec![access.role_id.clone()],
17182            }],
17183            roles: vec![access],
17184        };
17185        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17186        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17187
17188        // Everything else about this vend is valid — only the epoch is absurd.
17189        assert!(matches!(
17190            judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
17191            VendVerdict::Refuse(_)
17192        ));
17193        // REFUSED, not parked: a row nothing can ever discharge is its own leak.
17194        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
17195        let session = crate::db::current_session();
17196        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
17197        assert!(
17198            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17199            "and the row is discharged rather than parked forever"
17200        );
17201        // The head is untouched, so the genuine vend still lands afterwards.
17202        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17203        assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
17204        assert!(matches!(
17205            judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
17206            VendVerdict::Accept
17207        ));
17208    }
17209
17210    #[tokio::test]
17211    async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
17212        // The fold is the authority but runs later, so publishing alone leaves the
17213        // edit reading back stale — it looks like the rename silently failed.
17214        let (_tmp, _guard, _owner) = init_test_db();
17215        let relay = MemoryRelay::new();
17216        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17217        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17218        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17219        let key_before = held.channel(&priv_id).unwrap().key;
17220
17221        let mut meta = held.channel(&priv_id).unwrap().metadata();
17222        meta.name = "staff".into();
17223        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17224
17225        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17226        let ch = after.channel(&priv_id).unwrap();
17227        assert_eq!(ch.name, "staff", "the rename is visible immediately");
17228        assert!(ch.private, "and privacy survives the edit");
17229        assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
17230    }
17231
17232    #[tokio::test]
17233    async fn a_channel_rename_carries_its_companion_access_role() {
17234        let (_tmp, _guard, _owner) = init_test_db();
17235        let relay = MemoryRelay::new();
17236        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17237        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17238        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17239        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17240
17241        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17242        let before = roster.channel_roles(&chan_hex);
17243        assert_eq!(before.len(), 1, "one companion role, minted at create");
17244        assert_eq!(before[0].name, "mods", "named after the channel it gates");
17245        let role_id = before[0].role_id.clone();
17246
17247        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17248        let mut meta = held.channel(&priv_id).unwrap().metadata();
17249        meta.name = "staff".into();
17250        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17251
17252        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17253        let after = roster.channel_roles(&chan_hex);
17254        assert_eq!(after.len(), 1, "renamed in place, never duplicated");
17255        assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
17256        assert_eq!(after[0].name, "staff", "the access role followed the channel");
17257        assert_eq!(
17258            after[0].permissions,
17259            crate::community::roles::Permissions::empty(),
17260            "and still confers read access, never authority"
17261        );
17262    }
17263
17264    #[tokio::test]
17265    async fn a_customised_access_role_name_survives_a_channel_rename() {
17266        // The label is cosmetic — entitlement rides the scope. Overwriting a name
17267        // someone chose deliberately is the surprising half of "keep them in step".
17268        let (_tmp, _guard, _owner) = init_test_db();
17269        let relay = MemoryRelay::new();
17270        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
17271        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17272        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17273        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17274
17275        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17276        let mut role = roster.channel_roles(&chan_hex)[0].clone();
17277        role.name = "Lab Insiders".into();
17278        set_role(&relay, &community, &role).await.unwrap();
17279        merge_local_roster(&cid_hex, Some(&role), None);
17280
17281        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17282        let mut meta = held.channel(&priv_id).unwrap().metadata();
17283        meta.name = "staff".into();
17284        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
17285
17286        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
17287        assert_eq!(
17288            roster.channel_roles(&chan_hex)[0].name,
17289            "Lab Insiders",
17290            "a deliberate name is left alone"
17291        );
17292    }
17293
17294    #[tokio::test]
17295    async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
17296        // Parking is reachable by ANY npub that can gift-wrap us — the bundle
17297        // self-certifies and its inputs are public for a public community. With a
17298        // single slot per channel, a stranger could pre-park and the admin's real
17299        // vend would be a silent no-op, leaving the member keyless with no retry.
17300        // Candidates + judge-them-all is what closes that.
17301        let (bed, owner, member) = TestBed::new();
17302        bed.swap_to(&owner);
17303        let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
17304        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
17305        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17306        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
17307        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17308        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
17309        let owner_hex = community.owner().unwrap().to_hex();
17310
17311        bed.swap_to(&member);
17312        let mut member_view = held.clone();
17313        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
17314            c.key = None;
17315            c.epoch = Epoch(0);
17316        }
17317        crate::db::community::save_community_v2(&member_view).unwrap();
17318        let access = crate::community::roles::Role {
17319            role_id: "aa".repeat(32),
17320            name: "mods".into(),
17321            position: u32::MAX - 1,
17322            permissions: crate::community::roles::Permissions::empty(),
17323            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
17324            color: 0, extra: Default::default(),
17325        };
17326        let roster = crate::community::roles::CommunityRoles {
17327            grants: vec![crate::community::roles::MemberGrant {
17328                member: member.keys.public_key().to_hex(),
17329                role_ids: vec![access.role_id.clone()],
17330            }],
17331            roles: vec![access],
17332        };
17333        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
17334
17335        // A stranger squats FIRST, at a higher epoch than the genuine vend.
17336        let stranger = Keys::generate().public_key().to_hex();
17337        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
17338        // The admin's real vend arrives after, at the true epoch.
17339        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
17340        assert_eq!(
17341            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
17342            2,
17343            "the squatter never displaces the genuine vend — both are candidates"
17344        );
17345
17346        let session = crate::db::current_session();
17347        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17348        let adopted = absorb_parked_channel_keys(&reloaded, &session);
17349        assert_eq!(adopted.len(), 1, "exactly one adoption");
17350
17351        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17352        let ch = after.channel(&priv_id).unwrap();
17353        assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
17354        assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
17355        assert!(
17356            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
17357            "and every candidate for the channel is discharged"
17358        );
17359    }
17360
17361    #[tokio::test]
17362    async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
17363        // With no access role folded, the retained-set filter matches NOBODY, so
17364        // the rotation would cut off every legitimately entitled member while the
17365        // Grant it published revoked nothing. Reachable with no attacker: the
17366        // channel was made on another admin's client and its role hasn't folded.
17367        let (_tmp, _guard, _owner) = init_test_db();
17368        let relay = MemoryRelay::new();
17369        let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
17370        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
17371        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17372        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17373        let before = held.channel(&priv_id).unwrap().epoch;
17374
17375        // Neither the cache nor the plane serves the access role — a withholding
17376        // relay, or a channel minted on another admin's client. (Wiping only the
17377        // cache is no longer enough: the revoke re-fetches authority first.)
17378        crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
17379        let mut blind = held.clone();
17380        blind.relays = vec!["wss://empty".into()];
17381        let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
17382            .await
17383            .unwrap_err();
17384        assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
17385
17386        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17387        assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
17388    }
17389
17390    // ── Live rekey-follow ────────────────────────────────────────────────────
17391
17392    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
17393    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
17394    /// a stranger for the authority test); `prev_key` is the root it claims to
17395    /// extend (mismatch → a fork).
17396    async fn publish_base_rotation(
17397        relay: &MemoryRelay,
17398        community: &CommunityV2,
17399        rotator: &Keys,
17400        recipients: &[PublicKey],
17401        new_root: &[u8; 32],
17402        prev_key: &[u8; 32],
17403    ) {
17404        let new_epoch = Epoch(community.root_epoch.0 + 1);
17405        let prev_epoch = community.root_epoch;
17406        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
17407        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17408        let blobs: Vec<_> = recipients
17409            .iter()
17410            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
17411            .collect();
17412        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), false).unwrap();
17413        for e in &events {
17414            relay.publish(e, &community.relays).await.unwrap();
17415        }
17416    }
17417
17418    /// A base rotation that carries the containment marker (CORD-06 Severing).
17419    async fn publish_severing_base_rotation(
17420        relay: &MemoryRelay,
17421        community: &CommunityV2,
17422        rotator: &Keys,
17423        recipients: &[PublicKey],
17424        new_root: &[u8; 32],
17425        prev_key: &[u8; 32],
17426    ) {
17427        let new_epoch = Epoch(community.root_epoch.0 + 1);
17428        let prev_epoch = community.root_epoch;
17429        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
17430        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17431        let blobs: Vec<_> = recipients
17432            .iter()
17433            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
17434            .collect();
17435        let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000, my_authority_citation(community, &rotator.public_key()).as_ref(), true).unwrap();
17436        for e in &events {
17437            relay.publish(e, &community.relays).await.unwrap();
17438        }
17439    }
17440
17441    /// Attach a Private channel (key + epoch) to a held community and persist it.
17442    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
17443        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
17444        crate::db::community::save_community_v2(community).unwrap();
17445    }
17446
17447    #[tokio::test]
17448    async fn follow_rekeys_is_a_noop_without_rotations() {
17449        let (_tmp, _guard, _owner) = init_test_db();
17450        let relay = MemoryRelay::new();
17451        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
17452        let session = crate::db::current_session();
17453        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17454        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
17455    }
17456
17457    #[tokio::test]
17458    async fn follow_rekeys_adopts_an_owner_base_rotation() {
17459        let (_tmp, _guard, owner) = init_test_db();
17460        let relay = MemoryRelay::new();
17461        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
17462        let new_root = [0xB1; 32];
17463        // Owner rotates the base to epoch 1, delivering the new root to me.
17464        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
17465
17466        let session = crate::db::current_session();
17467        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17468        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
17469        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
17470        // The public channel now reads under the NEW root/epoch (its address moved).
17471        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
17472        let general = updated.channels[0].id;
17473        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
17474        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
17475    }
17476
17477    #[tokio::test]
17478    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
17479        let (_tmp, _guard, owner) = init_test_db();
17480        let relay = MemoryRelay::new();
17481        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
17482        let priv_id = ChannelId([0x33; 32]);
17483        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
17484
17485        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
17486        let new_key = [0x55; 32];
17487        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
17488        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17489        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &new_key).unwrap();
17490        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
17491        for e in &events {
17492            relay.publish(e, &community.relays).await.unwrap();
17493        }
17494
17495        let session = crate::db::current_session();
17496        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17497        let ch = updated.channel(&priv_id).unwrap();
17498        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
17499        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
17500        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
17501    }
17502
17503    #[tokio::test]
17504    async fn a_whole_channel_fan_rotates_in_one_hop_without_crossing_keys() {
17505        // The fan is fetched concurrently and keyed by (channel, addressing root). A
17506        // mis-keyed prefetch cannot hand channel A's key to channel B — the blob's
17507        // scope is authenticated, so a wrong batch is refused rather than adopted — but
17508        // it CAN starve a channel of the chunks that are sitting on the plane for it,
17509        // which reads as "that channel never rotates" and strands its members.
17510        let (_tmp, _guard, owner) = init_test_db();
17511        let relay = MemoryRelay::new();
17512        let mut community = create_community(&relay, "FanRot", vec!["wss://r".into()], None).await.unwrap();
17513
17514        // Distinct held key AND distinct fresh key per channel — a swap between any
17515        // two is then visible as an equality failure rather than a silent pass.
17516        let fan: Vec<(ChannelId, [u8; 32], [u8; 32])> = (0u8..12)
17517            .map(|i| (ChannelId([0x30 + i; 32]), [0x40 + i; 32], [0x80 + i; 32]))
17518            .collect();
17519        for (id, held, _) in &fan {
17520            add_private_channel(&mut community, *id, *held, Epoch(0));
17521        }
17522
17523        for (id, held, fresh) in &fan {
17524            let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), held);
17525            let group = channel_rekey_group_key(&community.community_root, id, Epoch(1));
17526            let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(*id), Epoch(1), fresh).unwrap();
17527            let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(*id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None, false).unwrap();
17528            for e in &events {
17529                relay.publish(e, &community.relays).await.unwrap();
17530            }
17531        }
17532
17533        let session = crate::db::current_session();
17534        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
17535        for (id, _, fresh) in &fan {
17536            let ch = updated.channel(id).expect("channel survived the fan");
17537            assert_eq!(ch.epoch, Epoch(1), "every channel in the fan advanced");
17538            assert_eq!(ch.key, Some(*fresh), "each channel adopted ITS OWN key, not a neighbour's");
17539        }
17540    }
17541
17542    #[tokio::test]
17543    async fn follow_rekeys_ignores_a_non_owner_rotation() {
17544        // A member holds the community_root, so they can derive the rekey group key
17545        // and mint a rotation — but they aren't the owner, so it's not adopted.
17546        let (_tmp, _guard, _owner) = init_test_db();
17547        let relay = MemoryRelay::new();
17548        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
17549        let rogue = Keys::generate();
17550        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
17551
17552        let session = crate::db::current_session();
17553        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17554        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
17555    }
17556
17557    #[tokio::test]
17558    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
17559        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
17560        // extension — never adopted (would splice me onto an unrelated chain).
17561        let (_tmp, _guard, owner) = init_test_db();
17562        let relay = MemoryRelay::new();
17563        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
17564        // prev_key ≠ the real community_root → the continuity check reads Fork.
17565        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
17566
17567        let session = crate::db::current_session();
17568        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17569        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
17570    }
17571
17572    #[tokio::test]
17573    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
17574        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
17575        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
17576        let (_tmp, _guard, owner) = init_test_db();
17577        let relay = MemoryRelay::new();
17578        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
17579        let new_epoch = Epoch(1);
17580        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17581        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
17582        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
17583        let other = Keys::generate();
17584        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
17585        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None, false).unwrap();
17586        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
17587        relay.publish(&wrap, &community.relays).await.unwrap();
17588
17589        let session = crate::db::current_session();
17590        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
17591        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
17592    }
17593
17594    #[tokio::test]
17595    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
17596        // Realistic two-actor removal: the owner Refounds the base and delivers the
17597        // new root to a THIRD party, not the member — a complete rotation with no
17598        // blob for the member is a removal.
17599        let (bed, owner, member) = TestBed::new();
17600        bed.swap_to(&owner);
17601        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
17602        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
17603
17604        bed.swap_to(&member);
17605        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
17606        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
17607
17608        // Owner rotates, delivering only to a stranger (the member is dropped).
17609        bed.swap_to(&owner);
17610        let stranger = Keys::generate();
17611        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
17612
17613        // The member's follow concludes removal (a complete rotation without their blob).
17614        bed.swap_to(&member);
17615        let session = crate::db::current_session();
17616        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
17617        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
17618        assert!(follow.updated.is_none(), "a removed member adopts nothing");
17619    }
17620
17621    #[tokio::test]
17622    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
17623        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
17624        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
17625        // the base crate landed and was walked before the channel crates) must
17626        // still find them — the lookup fans across the archived roots, not just
17627        // the current one.
17628        let (_tmp, _guard, owner) = init_test_db();
17629        let relay = MemoryRelay::new();
17630        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
17631        let root0 = community.community_root;
17632        let priv_id = ChannelId([0x33; 32]);
17633        let key1 = [0x44; 32];
17634        add_private_channel(&mut community, priv_id, key1, Epoch(1));
17635
17636        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
17637        // root (root0), delivering the fresh key to me.
17638        let key2 = [0x55; 32];
17639        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17640        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
17641        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17642        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None, false).unwrap() {
17643            relay.publish(&e, &community.relays).await.unwrap();
17644        }
17645
17646        // Simulate the base having ALREADY advanced (the stranding order): the head
17647        // moved to a fresh root while root0 sits in the epoch-key archive (where
17648        // genesis put it).
17649        community.community_root = [0xB7; 32];
17650        community.root_epoch = Epoch(1);
17651        crate::db::community::save_community_v2(&community).unwrap();
17652
17653        let session = crate::db::current_session();
17654        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
17655        let ch = updated.channel(&priv_id).unwrap();
17656        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
17657        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
17658    }
17659
17660    #[tokio::test]
17661    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
17662        // A keyless private channel (announced by vsk-2, key not yet held) has no
17663        // chain, so its epoch is a scan cursor: a complete rotation that excludes
17664        // us advances the cursor (never a removal — we were never in); a later
17665        // rotation that includes us is the entry point.
17666        let (_tmp, _guard, owner) = init_test_db();
17667        let relay = MemoryRelay::new();
17668        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
17669        let priv_id = ChannelId([0x66; 32]);
17670        community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
17671        crate::db::community::save_community_v2(&community).unwrap();
17672        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
17673        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
17674
17675        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
17676        let stranger = Keys::generate();
17677        let key1 = [0x71; 32];
17678        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
17679        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
17680        let b1 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(1), &key1).unwrap();
17681        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None, false).unwrap() {
17682            relay.publish(&e, &community.relays).await.unwrap();
17683        }
17684        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
17685        // recipient set is the CURRENT members).
17686        let key2 = [0x72; 32];
17687        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17688        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17689        let b2 = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &owner.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17690        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None, false).unwrap() {
17691            relay.publish(&e, &community.relays).await.unwrap();
17692        }
17693
17694        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
17695        // blob — adopt), because each real step re-loops.
17696        let session = crate::db::current_session();
17697        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
17698        let ch = updated.channel(&priv_id).unwrap();
17699        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
17700        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
17701    }
17702
17703    #[tokio::test]
17704    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
17705        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
17706        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
17707        // rotation keys members up; a mere keyholder's forgery never does.
17708        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17709        let (_tmp, _guard, _owner) = init_test_db();
17710        let relay = MemoryRelay::new();
17711        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
17712        let priv_id = ChannelId([0x88; 32]);
17713        let key1 = [0x91; 32];
17714        add_private_channel(&mut community, priv_id, key1, Epoch(1));
17715
17716        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
17717        let admin = Keys::generate();
17718        let role = Role::admin("aa".repeat(32));
17719        let roster = CommunityRoles {
17720            roles: vec![role.clone()],
17721            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
17722        };
17723        seed_roster_with_heads(&community, &roster, 1_000);
17724
17725        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
17726        let key2 = [0x92; 32];
17727        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17728        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
17729        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
17730        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17731        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &admin.public_key()).as_ref(), false).unwrap() {
17732            relay.publish(&e, &community.relays).await.unwrap();
17733        }
17734        let session = crate::db::current_session();
17735        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
17736        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
17737
17738        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
17739        let rogue = Keys::generate();
17740        let key3 = [0x93; 32];
17741        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
17742        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
17743        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
17744        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None, false).unwrap() {
17745            relay.publish(&e, &updated.relays).await.unwrap();
17746        }
17747        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
17748        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
17749    }
17750
17751    #[tokio::test]
17752    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
17753        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
17754        // target. An equal-rank bit-holder's complete rotation that skips my blob
17755        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
17756        // two-account bed: the follower must be a NON-owner admin.
17757        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
17758        let (bed, owner, member) = TestBed::new();
17759        bed.swap_to(&owner);
17760        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
17761
17762        // The MEMBER's device: holds the community + the private channel, with a
17763        // persisted roster granting the member AND a peer the same Admin role.
17764        bed.swap_to(&member);
17765        let mut held = community.clone();
17766        let priv_id = ChannelId([0xAB; 32]);
17767        let key1 = [0xA1; 32];
17768        add_private_channel(&mut held, priv_id, key1, Epoch(1));
17769        let peer = Keys::generate();
17770        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
17771        let role = Role::admin("bb".repeat(32));
17772        let roster = CommunityRoles {
17773            roles: vec![role.clone()],
17774            grants: vec![
17775                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17776                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
17777            ],
17778        };
17779        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
17780
17781        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
17782        let key2 = [0xA2; 32];
17783        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
17784        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
17785        let pb = rekey::build_blob_local(peer.secret_key(), &peer.public_key().to_bytes(), &peer.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
17786        for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000, my_authority_citation(&held, &peer.public_key()).as_ref(), false).unwrap() {
17787            bed.relay.publish(&e, &held.relays).await.unwrap();
17788        }
17789        let session = crate::db::current_session();
17790        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17791        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
17792        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
17793        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
17794
17795        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
17796        let key3 = [0xA3; 32];
17797        let stranger = Keys::generate();
17798        let ob = rekey::build_blob_local(owner.keys.secret_key(), &owner.keys.public_key().to_bytes(), &stranger.public_key(), RekeyScope::Channel(priv_id), Epoch(2), &key3).unwrap();
17799        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None, false).unwrap() {
17800            bed.relay.publish(&e, &held.relays).await.unwrap();
17801        }
17802        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
17803        let updated = follow.updated.expect("the owner's removal folds");
17804        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
17805    }
17806
17807    #[tokio::test]
17808    async fn converting_a_public_channel_to_private_is_refused() {
17809        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
17810        // the producer refuses the flag flip, so no reader is left unkeyable.
17811        let (_tmp, _guard, _owner) = init_test_db();
17812        let relay = MemoryRelay::new();
17813        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
17814        let general = community.channels[0].id;
17815        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
17816        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
17817        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
17818        // A rename of the same public channel still works.
17819        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
17820        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
17821    }
17822
17823    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
17824    /// `removed_at` — simulating a sibling device having left that community.
17825    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
17826        let doc = super::super::list::CommunityList {
17827            entries: vec![],
17828            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
17829            extra: Default::default(),
17830        };
17831        // A sibling device writes fragments, like any §8 client.
17832        for (i, frag) in super::super::list_frag::fragment(&doc).iter().enumerate() {
17833            let event = super::super::list_frag::build_fragment_event_keys(me, frag, i, now_ms() / 1000).unwrap();
17834            relay.publish(&event, relays).await.unwrap();
17835        }
17836    }
17837
17838    #[tokio::test]
17839    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
17840        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
17841        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
17842        // which would silently undo the leave everywhere.
17843        let (_tmp, _guard, me) = init_test_db();
17844        let relay = MemoryRelay::new();
17845        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
17846        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17847
17848        // A sibling leaves X: a remote tombstone strictly newer than X's add.
17849        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17850
17851        // Now join a different community Y → republish(just_joined = Y).
17852        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
17853        republish_community_list(&relay, Some(y.id())).await.unwrap();
17854
17855        // X must still read as LEFT in the published list; Y must be live.
17856        let list = fetch_fragments(&relay, &x.relays).await.unwrap().unwrap().list;
17857        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
17858        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
17859    }
17860
17861    #[tokio::test]
17862    async fn sync_tears_down_a_community_a_sibling_left() {
17863        // W1 (receive side): a community still held locally that the synced 13302
17864        // shows tombstoned-and-not-live is torn down, so a leave propagates.
17865        let (_tmp, _guard, me) = init_test_db();
17866        let relay = MemoryRelay::new();
17867        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
17868        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17869        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
17870
17871        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
17872        sync_community_list(&relay, &x.relays).await.unwrap();
17873        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
17874    }
17875
17876    #[tokio::test]
17877    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
17878        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
17879        // the tombstone), so a later sync keeps it.
17880        let (_tmp, _guard, me) = init_test_db();
17881        let relay = MemoryRelay::new();
17882        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
17883        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
17884        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
17885        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
17886        // Re-record the membership (a re-join) → live entry at now >> 1.
17887        republish_community_list(&relay, Some(x.id())).await.unwrap();
17888        sync_community_list(&relay, &x.relays).await.unwrap();
17889        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
17890    }
17891
17892    #[tokio::test]
17893    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
17894        // W2: a transient fetch failure during republish must not drive the
17895        // replaceable-event write (which would drop other entries / regress seeds).
17896        let (_tmp, _guard, _me) = init_test_db();
17897        let good = MemoryRelay::new();
17898        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
17899        assert!(fetch_fragments(&good, &community.relays).await.unwrap().is_some());
17900
17901        // A transport whose fetch always errors: republish must bail, publishing nothing.
17902        struct FetchErrors;
17903        #[async_trait::async_trait]
17904        impl Transport for FetchErrors {
17905            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
17906            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17907                panic!("republish must NOT publish when the remote fetch failed");
17908            }
17909            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
17910                Ok(())
17911            }
17912            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
17913                Err("relay unreachable".to_string())
17914            }
17915        }
17916        // Returns Ok (best-effort) but must not have published (the panic guards it).
17917        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
17918    }
17919
17920    #[tokio::test]
17921    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
17922        // B1 regression: refound_community's recipient set = memberlist. A member
17923        // the owner GRANTED a role to but who never left a (surviving) Guestbook
17924        // Join — a lurking admin, or one whose Join aged out of the window — must
17925        // still be a rekey recipient, or the Refounding SEVERS them. The folded
17926        // roster's granted members are the consensus-complete backstop.
17927        let (_tmp, _guard, owner) = init_test_db();
17928        let relay = MemoryRelay::new();
17929        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
17930
17931        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
17932        let lurker = Keys::generate();
17933        let rid = "b1".repeat(32);
17934        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
17935        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
17936
17937        // memberlist includes the lurker purely via the roster backstop.
17938        let members = memberlist(&relay, &community).await.unwrap();
17939        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
17940
17941        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
17942        let banned_grantee = Keys::generate();
17943        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
17944        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
17945        let members = memberlist(&relay, &community).await.unwrap();
17946        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
17947        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
17948
17949        // And the Refounding actually delivers the new root to the lurker.
17950        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
17951        assert_eq!(refounded.root_epoch, Epoch(1));
17952        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
17953        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
17954        let rotations = rekey::collect_rotations(&chunks);
17955        let lurker_x = lurker.public_key().to_bytes();
17956        let delivered = rotations.iter().any(|r| {
17957            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
17958        });
17959        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
17960    }
17961
17962    #[tokio::test]
17963    async fn the_memberlist_pages_past_a_guestbook_flood() {
17964        // The roleless-member half of B1: >500 Guestbook events must not evict an
17965        // honest member's Join from the counted set (an insider can flood throwaway
17966        // Joins to force exactly this). The pager sees them all.
17967        let (_tmp, _guard, _owner) = init_test_db();
17968        let relay = MemoryRelay::new();
17969        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
17970        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
17971
17972        // An honest member's Join (oldest), then 600 throwaway Joins on top.
17973        let honest = Keys::generate();
17974        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
17975        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
17976        relay.publish(&w, &community.relays).await.unwrap();
17977        for i in 0..600u64 {
17978            let throwaway = Keys::generate();
17979            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
17980            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
17981            relay.publish(&w, &community.relays).await.unwrap();
17982        }
17983
17984        let members = memberlist(&relay, &community).await.unwrap();
17985        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
17986    }
17987
17988    #[tokio::test]
17989    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
17990        // An insider floods the next-epoch rekey address (community_root-derived,
17991        // so any member can seal there) with >200 junk 3303s to push the owner's
17992        // genuine rotation out of a single fetch window. The paginated fetch must
17993        // still recover it and adopt.
17994        let (_tmp, _guard, owner) = init_test_db();
17995        let relay = MemoryRelay::new();
17996        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
17997        let new_root = [0xD9; 32];
17998        let new_epoch = Epoch(1);
17999        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
18000
18001        // The GENUINE owner rotation lands first (oldest).
18002        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
18003
18004        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
18005        // (newer), burying the genuine one past the 200 newest.
18006        let rogue = Keys::generate();
18007        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
18008        for i in 0..260u64 {
18009            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
18010            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None, false).unwrap();
18011            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
18012            relay.publish(&wrap, &community.relays).await.unwrap();
18013        }
18014
18015        let session = crate::db::current_session();
18016        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
18017        assert_eq!(updated.root_epoch, Epoch(1));
18018        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
18019    }
18020
18021    #[tokio::test]
18022    async fn a_swap_during_create_private_channel_lands_in_the_creating_account() {
18023        // create_private_channel publishes the key crate, then the channel
18024        // edition, then whole-row-saves. A swap in that window used to abandon
18025        // the whole thing — leaving the key crate published to relays but no
18026        // local channel to open it, which is the worse of the two outcomes.
18027        //
18028        // The operation is pinned to the account that started it, so it now
18029        // finishes into that account's storage, and the account swapped in gets
18030        // nothing.
18031        let (bed, owner, _member) = TestBed::new();
18032        bed.swap_to(&owner);
18033        let creator = crate::db::current_session();
18034        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
18035        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
18036
18037        // The key-crate publish inside create swaps the account mid-flight.
18038        let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
18039        create_private_channel(&swap_relay, &community, "ghost").await.unwrap();
18040
18041        let mine = crate::db::with_session(creator, async {
18042            crate::db::community::load_community_v2(community.id()).unwrap().unwrap()
18043        })
18044        .await;
18045        assert_eq!(mine.channels.len(), before + 1, "the channel is minted for the account that asked");
18046        assert!(mine.channels.iter().any(|c| c.name == "ghost"), "and it is the one requested");
18047    }
18048
18049    #[tokio::test]
18050    async fn an_uncited_admin_rotation_is_not_adopted() {
18051        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
18052        // authority action, so a just-demoted admin's rotation is never honored by
18053        // a lagging client." An uncited rotation is skipped entirely — neither
18054        // adopted nor allowed to conclude a removal.
18055        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
18056        let (_tmp, _guard, _owner) = init_test_db();
18057        let relay = MemoryRelay::new();
18058        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
18059        let priv_id = ChannelId([0x8A; 32]);
18060        let key1 = [0x93; 32];
18061        add_private_channel(&mut community, priv_id, key1, Epoch(1));
18062
18063        let admin = Keys::generate();
18064        let role = Role::admin("cf".repeat(32));
18065        let roster = CommunityRoles {
18066            roles: vec![role.clone()],
18067            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
18068        };
18069        seed_roster_with_heads(&community, &roster, 1_000);
18070
18071        let key2 = [0x94; 32];
18072        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
18073        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
18074        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
18075        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
18076        // Authorized admin, correct continuity, my blob present — but NO citation.
18077        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None, false).unwrap() {
18078            relay.publish(&e, &community.relays).await.unwrap();
18079        }
18080
18081        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
18082        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
18083
18084        // The SAME rotation, cited, is adopted — proving the refusal was the
18085        // citation and not the rank or the continuity.
18086        let cited = my_authority_citation(&community, &admin.public_key());
18087        assert!(cited.is_some(), "the seeded head yields a citation");
18088        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
18089        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob2], 2_100, cited.as_ref(), false).unwrap() {
18090            relay.publish(&e, &community.relays).await.unwrap();
18091        }
18092        let out = follow_rekeys(&relay, &community, &crate::db::current_session()).await.unwrap();
18093        assert!(out.updated.is_some(), "the cited rotation IS adopted");
18094    }
18095
18096    #[tokio::test]
18097    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
18098        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
18099        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
18100        // Every follower must converge on the SAME key (the lexicographically
18101        // lowest), so the community never permanently forks. (Retaining the losing
18102        // fork's key for its race-window messages needs a multi-key-per-epoch
18103        // archive — a deferred refinement shared with v1; convergence, the
18104        // security-critical property, is what this pins.)
18105        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
18106        let (_tmp, _guard, _owner) = init_test_db();
18107        let relay = MemoryRelay::new();
18108        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
18109        let priv_id = ChannelId([0xC0; 32]);
18110        let key1 = [0xC1; 32];
18111        add_private_channel(&mut community, priv_id, key1, Epoch(1));
18112
18113        // Two admins (a, b) both hold the Admin role; I hold the channel key.
18114        let (a, b) = (Keys::generate(), Keys::generate());
18115        let role = Role::admin("ce".repeat(32));
18116        let roster = CommunityRoles {
18117            roles: vec![role.clone()],
18118            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
18119        };
18120        seed_roster_with_heads(&community, &roster, 1_000);
18121
18122        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
18123        // same prevcommit — a genuine same-epoch fork.
18124        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
18125        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
18126        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
18127        let key_a = [0x0A; 32];
18128        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
18129        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
18130            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
18131            for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, my_authority_citation(&community, &signer.public_key()).as_ref(), false).unwrap() {
18132                relay.publish(&e, &community.relays).await.unwrap();
18133            }
18134        }
18135
18136        let session = crate::db::current_session();
18137        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
18138        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
18139        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
18140
18141        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
18142        let mut peer = community.clone();
18143        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
18144            c.key = Some(key1);
18145            c.epoch = Epoch(1);
18146        }
18147        // Re-run the same fold from the peer's identical starting point → same winner.
18148        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
18149        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
18150    }
18151
18152    #[tokio::test]
18153    async fn create_private_channel_refuses_a_member_without_manage_channels() {
18154        // The local mirror of the reader's gate: an unauthorized member is refused
18155        // BEFORE any publish (no floor pollution, no orphan key crate).
18156        let (bed, owner, member) = TestBed::new();
18157        bed.swap_to(&owner);
18158        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
18159        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
18160
18161        bed.swap_to(&member);
18162        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
18163        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
18164        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
18165        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
18166        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
18167        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
18168    }
18169
18170    // ── Audit regressions ────────────────────────────────────────────────────
18171
18172    #[tokio::test]
18173    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
18174        // The eclipse: community_id commits only to (owner, salt) — both semi-public
18175        // — so a forged invite pairs the REAL triple with an attacker root, and every
18176        // plane derives from it. The join-time owner-genesis check must refuse.
18177        let (bed, owner, member) = TestBed::new();
18178        bed.swap_to(&owner);
18179        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
18180
18181        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
18182        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
18183        forged.community_root = fake.clone();
18184        for ch in &mut forged.channels {
18185            ch.key = fake.clone();
18186        }
18187        let attacker = Keys::generate();
18188        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
18189        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18190
18191        bed.swap_to(&member);
18192        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
18193        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
18194        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
18195        assert!(
18196            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
18197            "a rejected join persists nothing"
18198        );
18199    }
18200
18201    #[tokio::test]
18202    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
18203        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
18204        // so a rotated plane whose metadata an admin last edited carries no
18205        // owner-signed vsk-0. The join anchor there is the community-bound metadata
18206        // head plus any owner-signed edition under the same root.
18207        let (bed, owner, member) = TestBed::new();
18208        bed.swap_to(&owner);
18209        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
18210        let general = community.channels[0].id;
18211
18212        let rotated = rotate_view(&community, [0x5A; 32], 1);
18213        let admin = Keys::generate();
18214        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
18215        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
18216
18217        bed.swap_to(&member);
18218        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
18219        let joined = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18220        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
18221    }
18222
18223    #[tokio::test]
18224    async fn only_an_actual_join_publishes_a_guestbook_join() {
18225        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
18226        // a held community and a cross-device key sync (announce_join=false) must
18227        // both stay silent — each re-publish renders as "<user> has joined" spam.
18228        let (bed, owner, member) = TestBed::new();
18229        bed.swap_to(&owner);
18230        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
18231
18232        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
18233        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
18234            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
18235            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
18236        }
18237        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
18238
18239        bed.swap_to(&member);
18240        let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
18241        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18242        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
18243
18244        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18245        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
18246
18247        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18248        crate::db::community::delete_community(&cid_hex).unwrap();
18249        // A key sync is not a membership event, and it is not a membership
18250        // RECORD either: the entry was just read from the very list a republish
18251        // would rewrite. A device adopting N entries used to rebuild and
18252        // publish the whole document N times over, concurrently, on boot.
18253        // 13302 is REPLACEABLE, so a republish swaps the stored event rather
18254        // than adding one — the id is the tell, and it always changes on a
18255        // real republish because the list is NIP-44 sealed with a random nonce.
18256        async fn list_id(relay: &MemoryRelay, me: &PublicKey, relays: &[String]) -> Option<String> {
18257            let q = Query { kinds: vec![crate::community::v2::kind::COMMUNITY_LIST_FRAG], authors: vec![me.to_hex()], ..Default::default() };
18258            let mut evs = relay.fetch(&q, relays).await.unwrap_or_default();
18259            evs.sort_by_key(|e| e.created_at.as_secs());
18260            evs.last().map(|e| e.id.to_hex())
18261        }
18262        let my_pk = me_pk().unwrap();
18263        let list_before = list_id(&bed.relay, &my_pk, &bed.relays).await;
18264        accept_bundle(&bed.relay, &bundle, None, false).await.unwrap();
18265        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
18266        assert_eq!(
18267            list_id(&bed.relay, &my_pk, &bed.relays).await,
18268            list_before,
18269            "adopting an entry FROM the list must not republish the list"
18270        );
18271
18272        // A genuine join still records, so the storm fix can't have muted the
18273        // thing the republish exists for.
18274        crate::db::community::delete_community(&cid_hex).unwrap();
18275        accept_bundle(&bed.relay, &bundle, None, true).await.unwrap();
18276        assert_ne!(
18277            list_id(&bed.relay, &my_pk, &bed.relays).await,
18278            list_before,
18279            "a real join still records the membership across devices"
18280        );
18281    }
18282
18283    #[tokio::test]
18284    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
18285        // The fallback's second half is load-bearing: a community-bound metadata
18286        // head alone is self-signable by anyone who knows the (public) community_id.
18287        let (bed, owner, member) = TestBed::new();
18288        bed.swap_to(&owner);
18289        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
18290
18291        let rotated = rotate_view(&community, [0x5B; 32], 1);
18292        let attacker = Keys::generate();
18293        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
18294
18295        bed.swap_to(&member);
18296        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
18297        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
18298        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
18299    }
18300
18301    #[tokio::test]
18302    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
18303        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
18304        // an owner-signed genesis, so owner material without it stays insufficient.
18305        let (bed, owner, member) = TestBed::new();
18306        bed.swap_to(&owner);
18307        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
18308        let general = community.channels[0].id;
18309
18310        let mut fake = community.clone();
18311        fake.community_root = [0x5C; 32]; // epoch stays 0
18312        let admin = Keys::generate();
18313        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
18314        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
18315
18316        bed.swap_to(&member);
18317        let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
18318        let err = accept_bundle(&bed.relay, &bundle, None, true).await.unwrap_err();
18319        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
18320    }
18321
18322    #[tokio::test]
18323    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
18324        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
18325        // joiner addresses it at a plane only the attacker reads. The owner's genuine
18326        // public:false edition must override it on follow.
18327        let (_tmp, _guard, _owner) = init_test_db();
18328        let relay = MemoryRelay::new();
18329        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
18330        let general = community.channels[0].id;
18331        let mut poisoned = community.clone();
18332        poisoned.channels[0].private = true;
18333        poisoned.channels[0].key = Some([0x66; 32]);
18334        crate::db::community::save_community_v2(&poisoned).unwrap();
18335
18336        let healed = follow_control(&relay, &poisoned).await.unwrap().updated.expect("healed");
18337        let ch = healed.channel(&general).unwrap();
18338        assert!(!ch.private, "the owner's public declaration overrides the bundle");
18339        assert_eq!(ch.key, None, "a healed public channel derives from the root");
18340    }
18341
18342    #[tokio::test]
18343    async fn a_deleted_channel_does_not_resurrect_on_reload() {
18344        // save_community_v2 must prune orphan channel rows, or a control-follow delete
18345        // reappears (with a stale key) on the next reload.
18346        let (_tmp, _guard, owner) = init_test_db();
18347        let relay = MemoryRelay::new();
18348        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
18349        let extra = ChannelId([0x77; 32]);
18350        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
18351        let with_extra = follow_control(&relay, &community).await.unwrap().updated.unwrap();
18352        assert!(with_extra.channel(&extra).is_some());
18353        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
18354        let after = follow_control(&relay, &with_extra).await.unwrap().updated.unwrap();
18355        assert!(after.channel(&extra).is_none());
18356
18357        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
18358        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
18359        assert_eq!(reloaded.channels.len(), 1);
18360    }
18361
18362    #[tokio::test]
18363    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
18364        // channel_id is the sole DB primary key, so a bundle/replay reusing another
18365        // community's channel_id must NOT overwrite that row. It's skipped (not an
18366        // error — erroring would wedge all of this community's control persistence).
18367        let (_tmp, _guard, _owner) = init_test_db();
18368        let relay = MemoryRelay::new();
18369        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
18370        let a_channel = a.channels[0].id;
18371        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
18372        let b_channel = b.channels[0].id;
18373        // B's set includes a phantom whose id collides with A's channel.
18374        b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
18375
18376        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
18377        // A's channel row is untouched.
18378        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
18379        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
18380        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
18381        // B keeps its own channel but never acquired a row for the foreign id.
18382        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
18383        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
18384        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
18385    }
18386
18387    /// A single relay that CAPS every query below the page size (modelling a real
18388    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
18389    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
18390    /// truncates the whole set), which is why a MemoryRelay flood test gives false
18391    /// confidence about the production `LiveTransport` behaviour.
18392    struct CappedRelay {
18393        events: Vec<Event>,
18394        cap: usize,
18395    }
18396    #[async_trait::async_trait]
18397    impl Transport for CappedRelay {
18398        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
18399        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18400            Ok(())
18401        }
18402        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
18403            Ok(())
18404        }
18405        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
18406            let mut m: Vec<Event> = self
18407                .events
18408                .iter()
18409                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
18410                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
18411                .cloned()
18412                .collect();
18413            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
18414            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
18415            Ok(m)
18416        }
18417    }
18418
18419    #[tokio::test]
18420    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
18421        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
18422        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
18423        // no cursor steps past — everything older (the genesis editions, a Banlist)
18424        // is unreachable. Compacting THAT view carries only what was read into the
18425        // new epoch, dropping the rest for every member, permanently. Any member can
18426        // build the wall: the plane key comes from the community root they hold.
18427        let (_tmp, _guard, _owner) = init_test_db();
18428        let memory = MemoryRelay::new();
18429        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
18430        // Post-split the plane is staff-write-only, so the wall is built with the
18431        // write group (any control_root holder can still flood; the pager
18432        // property under test is unchanged).
18433        let control = control::ControlPlane::of(&community).write_group().unwrap();
18434
18435        let rogue = Keys::generate();
18436        let mut events: Vec<Event> = Vec::new();
18437        for i in 0..FOLLOW_PAGE {
18438            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
18439            let rumor = control::build_edition_rumor(
18440                rogue.public_key(),
18441                vsk::CHANNEL_METADATA,
18442                &[0xAB; 32],
18443                1,
18444                None,
18445                &content,
18446                9_000,
18447                None,
18448            );
18449            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
18450            events.push(w);
18451        }
18452        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
18453
18454        let err = refound_community(&relay, &community, &[])
18455            .await
18456            .expect_err("a plane that can't be read whole must never be compacted");
18457        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
18458    }
18459
18460    #[tokio::test]
18461    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
18462        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
18463        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
18464        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
18465        // timestamps so `until = now` includes everything and the walk is deterministic.
18466        let (_tmp, _guard, owner) = init_test_db();
18467        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
18468        let g = control::genesis(&owner, meta, 1_000).unwrap();
18469        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
18470
18471        let control = control::ControlPlane::of(&community).write_group().unwrap();
18472        let rogue = Keys::generate();
18473        let mut events: Vec<Event> = g.wraps.to_vec();
18474        for i in 0..250u64 {
18475            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
18476            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
18477            events.push(wrap);
18478        }
18479        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
18480        let relay = CappedRelay { events, cap: 100 };
18481        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
18482        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
18483    }
18484
18485    #[tokio::test]
18486    async fn accept_parked_invite_joins_from_the_stored_bundle() {
18487        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
18488        // from the stored bundle (re-verifying the owner root over the network).
18489        let (bed, owner, member) = TestBed::new();
18490        bed.swap_to(&owner);
18491        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
18492        let general = community.channels[0].id;
18493        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
18494        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
18495        let bundle_json = serde_json::to_string(&bundle).unwrap();
18496        let inviter_hex = owner.keys.public_key().to_hex();
18497
18498        bed.swap_to(&member);
18499        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
18500        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
18501        assert!(joined.identity.verify());
18502        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
18503        // The join seeded the verified fold as the member's initial floor, so their
18504        // first follow can't roll below the state the join just showed.
18505        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
18506        assert!(
18507            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
18508            "the joiner's control floor is seeded from the join-time fold"
18509        );
18510
18511        // The Guestbook memberlist now folds both participants.
18512        bed.swap_to(&owner);
18513        let members = memberlist(&bed.relay, &community).await.unwrap();
18514        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
18515    }
18516
18517    #[tokio::test]
18518    async fn accept_parked_invite_rejects_a_forged_root() {
18519        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
18520        // accept — the shared accept path re-verifies the owner root, so a parked invite
18521        // gets the same eclipse protection as a live one.
18522        let (_tmp, _guard, _owner) = init_test_db();
18523        let relay = MemoryRelay::new();
18524        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
18525        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
18526        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
18527        forged.community_root = fake.clone();
18528        for ch in &mut forged.channels {
18529            ch.key = fake.clone();
18530        }
18531        let bundle_json = serde_json::to_string(&forged).unwrap();
18532
18533        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
18534        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
18535    }
18536
18537    #[test]
18538    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
18539        // The protocol discriminator the facade list/accept relies on: a v2 bundle
18540        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
18541        // one does not, so a parked invite routes to the right accept path.
18542        let owner = Keys::generate();
18543        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
18544        let hex = crate::simd::hex::bytes_to_hex_32;
18545        let v2 = invite::CommunityInvite {
18546            community_id: hex(&identity.community_id.0),
18547            owner: hex(&identity.owner_xonly),
18548            owner_salt: hex(&identity.owner_salt),
18549            community_root: hex(&[0x11; 32]),
18550            root_epoch: 0,
18551            control_pk: None,
18552            channels: vec![],
18553            relays: vec!["wss://r".into()],
18554            name: "V2".into(),
18555            icon: None,
18556            expires_at: None,
18557            creator_npub: None,
18558            label: None,
18559            extra: Default::default(),
18560        };
18561        let v2_json = serde_json::to_string(&v2).unwrap();
18562        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
18563        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
18564        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
18565    }
18566
18567    #[tokio::test]
18568    async fn verify_rejects_a_cross_community_owner_edition_replay() {
18569        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
18570        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
18571        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
18572        // a different eid — is not a valid proof of T's root. This is why "any owner
18573        // edition" is unsound and the eid==community_id genesis pin is required.
18574        let (_tmp, _guard, owner) = init_test_db();
18575
18576        // Community X (real), owned by `owner`.
18577        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
18578        let x_control = control::split_write_group(&gx.control_root, &gx.community_root, &gx.identity.community_id, Epoch(0));
18579        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
18580
18581        // Forged community T: the real owner triple but an ATTACKER-chosen root.
18582        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
18583        let fake_root = [0xEE; 32];
18584        let t = CommunityV2 {
18585            identity: t_identity,
18586            community_root: fake_root,
18587            root_epoch: Epoch(0),
18588            control_pk: None,
18589            control_root: None,
18590            name: "T".into(),
18591            description: None,
18592            icon: None,
18593            banner: None,
18594            meta_custom: None,
18595            meta_extra: Default::default(),
18596            relays: vec!["wss://r".into()],
18597            channels: vec![],
18598            dissolved: false,
18599            created_at_ms: 0,
18600        };
18601        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
18602        // controls the fake root, so they can derive its control group key).
18603        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
18604        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
18605        let relay = MemoryRelay::new();
18606        relay.publish(&replayed, &t.relays).await.unwrap();
18607
18608        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
18609        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
18610    }
18611
18612    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
18613    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
18614    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
18615    /// it never touches real accounts. Run explicitly:
18616    /// ```sh
18617    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
18618    /// ```
18619    #[tokio::test]
18620    #[ignore = "hits a real relay over the network"]
18621    async fn live_smoke_create_send_fetch_on_a_real_relay() {
18622        use crate::community::transport::LiveTransport;
18623        use nostr_sdk::prelude::ToBech32;
18624
18625        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
18626        let relays = vec![relay.clone()];
18627
18628        // Isolated account + data dir (a fresh throwaway key — never a real account).
18629        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
18630        crate::db::close_database();
18631        crate::db::clear_id_caches();
18632        let tmp = tempfile::tempdir().unwrap();
18633        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
18634        // into elsewhere; otherwise a fresh throwaway.
18635        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
18636            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
18637            Err(_) => Keys::generate(),
18638        };
18639        let npub = keys.public_key().to_bech32().unwrap();
18640        // Off by default (never leak secrets from a committed test); set
18641        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
18642        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
18643            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
18644        }
18645        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
18646        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
18647        crate::db::set_current_account(npub.clone()).unwrap();
18648        crate::db::init_database(&npub).unwrap();
18649        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
18650        crate::state::set_my_public_key(keys.public_key());
18651        println!("[smoke] throwaway identity {npub}");
18652
18653        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
18654        let client = crate::nostr_client_builder().build();
18655        client.add_managed_relay(relay.as_str()).await.ok();
18656        client.connect().await;
18657        crate::state::set_nostr_client(client);
18658        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
18659
18660        // Create → send → fetch-back → verify.
18661        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
18662        let general = community.channels[0].id;
18663        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
18664
18665        let text = "hello from a Vector Concord v2 live smoke test";
18666        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
18667        println!("[smoke] sent message {sent_id}");
18668
18669        // Give the relay a moment to store + be ready to serve it.
18670        crate::rt::time::sleep(std::time::Duration::from_secs(3)).await;
18671
18672        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
18673        let texts: Vec<String> = page
18674            .iter()
18675            .filter_map(|f| match &f.event {
18676                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
18677                _ => None,
18678            })
18679            .collect();
18680        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
18681        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
18682
18683        // Mint a shareable v2 link (the thing a bot hands out).
18684        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
18685        println!("[smoke] invite link: {}", link.url);
18686        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
18687    }
18688
18689    #[tokio::test]
18690    async fn chat_ops_react_edit_delete_round_trip() {
18691        let (bed, owner, _member) = TestBed::new();
18692        bed.swap_to(&owner);
18693        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
18694        let general = community.channels[0].id;
18695        let me_hex = owner.keys.public_key().to_hex();
18696
18697        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
18698        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
18699            .await
18700            .unwrap();
18701        send_edit(&bed.relay, &community, &general, &msg_id, "edited", &[]).await.unwrap();
18702        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
18703
18704        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18705        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
18706        let mut saw = (false, false, false);
18707        for f in &page {
18708            match &f.event {
18709                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
18710                    assert_eq!(emoji, ":fire:");
18711                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
18712                    saw.0 = true;
18713                }
18714                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
18715                    assert_eq!(new_content, "edited");
18716                    saw.1 = true;
18717                }
18718                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
18719                _ => {}
18720            }
18721        }
18722        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
18723    }
18724
18725    #[tokio::test]
18726    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
18727        let (bed, owner, _member) = TestBed::new();
18728        bed.swap_to(&owner);
18729        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
18730        let general = community.channels[0].id;
18731        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18732
18733        // A live subscriber sees the 21059 wrap and it opens as Typing…
18734        let mut sub = bed.relay.subscribe(Query {
18735            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
18736            authors: vec![group.pk_hex()],
18737            ..Default::default()
18738        });
18739        send_typing(&bed.relay, &community, &general).await.unwrap();
18740        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
18741        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
18742            Ok(ChatEvent::Typing { opened }) => opened,
18743            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
18744        };
18745
18746        // …while nothing durable is stored (relays never keep the ephemeral tier),
18747        // so channel history stays free of typing noise…
18748        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18749        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
18750
18751        // …and no scrub key is retained (there is no durable wrap to ever delete).
18752        assert!(
18753            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
18754            "ephemeral sends must not retain scrub keys"
18755        );
18756    }
18757
18758    #[tokio::test]
18759    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
18760        let (bed, owner, _member) = TestBed::new();
18761        bed.swap_to(&owner);
18762        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
18763        let general = community.channels[0].id;
18764        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18765
18766        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
18767
18768        // Retained: the row maps the rumor id to the exact published wrap, holds the
18769        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
18770        let (keys, outer_hex, relays) =
18771            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
18772        assert_eq!(relays, community.relays);
18773        let wrap_query = Query {
18774            kinds: vec![stream::KIND_WRAP],
18775            authors: vec![group.pk_hex()],
18776            ..Default::default()
18777        };
18778        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18779        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
18780        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
18781
18782        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
18783        let me_hex = owner.keys.public_key().to_hex();
18784        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
18785            .await
18786            .unwrap();
18787        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
18788
18789        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
18790        // scrubs the wrap off the relay via the retained key, then consumes the row.
18791        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18792        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
18793        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
18794        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
18795    }
18796
18797    #[tokio::test]
18798    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
18799        let (bed, owner, _member) = TestBed::new();
18800        bed.swap_to(&owner);
18801        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
18802        let general = community.channels[0].id;
18803        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
18804
18805        // Simulate a pre-retention / other-device send: our message on the relay,
18806        // but no local mapping row.
18807        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
18808        crate::db::community::delete_message_key(&id).unwrap();
18809        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
18810
18811        // A stranger member's message rides the same channel.
18812        let mkeys = Keys::generate();
18813        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
18814        let foreign_id = rumor.id.unwrap().to_hex();
18815        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
18816        bed.relay.publish(&fw, &community.relays).await.unwrap();
18817
18818        // One history open re-derives the mapping for the OWN message…
18819        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18820        let (keys, _outer, relays) =
18821            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
18822        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
18823        assert_eq!(relays, community.relays);
18824
18825        // …and never manufactures one for a foreign author.
18826        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
18827
18828        // The healed row is a working full delete: the shared path scrubs the wrap.
18829        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
18830        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18831        assert!(
18832            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
18833            "healed message scrubbed from the relay"
18834        );
18835    }
18836
18837    #[tokio::test]
18838    async fn send_chat_message_threads_the_reply_and_extra_tags() {
18839        let (bed, owner, _member) = TestBed::new();
18840        bed.swap_to(&owner);
18841        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
18842        let general = community.channels[0].id;
18843        let me_hex = owner.keys.public_key().to_hex();
18844
18845        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
18846        let imeta = nostr_sdk::prelude::Tag::custom(
18847            "imeta",
18848            ["url https://e/blob".to_string(), "m image/png".to_string()],
18849        );
18850        let child_id = send_chat_message(
18851            &bed.relay, &community, &general, "child",
18852            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
18853        )
18854        .await
18855        .unwrap();
18856
18857        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
18858        let child = page
18859            .iter()
18860            .find_map(|f| match &f.event {
18861                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
18862                _ => None,
18863            })
18864            .expect("the reply message round-trips");
18865        let reply = child.1.as_ref().expect("the reply reference is carried");
18866        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
18867        assert_eq!(reply.author, Some(owner.keys.public_key()));
18868        assert!(
18869            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
18870            "the imeta attachment tag rides the rumor verbatim"
18871        );
18872    }
18873
18874    #[tokio::test]
18875    async fn a_kick_needs_kick_authority_and_removes_the_target() {
18876        let (bed, owner, member) = TestBed::new();
18877        bed.swap_to(&owner);
18878        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
18879
18880        // The target announces a Join (as an accepted invite would).
18881        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
18882        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
18883        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
18884        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
18885        let before = memberlist(&bed.relay, &community).await.unwrap();
18886        assert!(before.contains(&member.keys.public_key()), "the join lands first");
18887
18888        // An unprivileged member's kick of the owner is refused locally…
18889        bed.swap_to(&member);
18890        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
18891        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
18892
18893        // …and the owner (supreme, no grant needed) kicks the member out.
18894        bed.swap_to(&owner);
18895        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
18896        let after = memberlist(&bed.relay, &community).await.unwrap();
18897        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
18898        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
18899    }
18900
18901    #[tokio::test]
18902    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
18903        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
18904        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
18905        // legitimately excludes them for that window. A stale Kick landing there used to
18906        // read as an authorized eviction and the client nuked its own community.
18907        let (bed, owner, member) = TestBed::new();
18908        bed.swap_to(&owner);
18909        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
18910        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18911        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
18912        let join = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18913            rumor_id: [id; 32],
18914            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
18915        };
18916        let kick = |at: u64, id: u8| guestbook::GuestbookEvent { epoch: None, observed_at: None,
18917            rumor_id: [id; 32],
18918            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
18919        };
18920
18921        // An authorized kick after their join stands.
18922        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
18923        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
18924
18925        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
18926        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
18927        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
18928
18929        // The catch-up window itself: nothing folded yet decides nothing.
18930        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
18931        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
18932
18933        // And the memberlist is NOT a substitute: with the store empty it excludes them,
18934        // which is exactly the false positive this verdict replaced.
18935        assert!(
18936            !stored_memberlist(&community).unwrap().contains(&m),
18937            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
18938        );
18939    }
18940
18941    /// Seed a roster the way production does: `follow_control` writes the roster
18942    /// AND the folded edition heads in one pass, so a citation against a grant is
18943    /// resolvable. Seeding the roster alone yields a client that can never satisfy
18944    /// any `vac` — a shape no v2 production path produces.
18945    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
18946        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
18947        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
18948        for g in &roster.grants {
18949            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
18950            let eid = super::super::derive::grant_locator(community.id(), &m);
18951            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
18952            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
18953        }
18954    }
18955
18956    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
18957    async fn publish_grant_citing(
18958        relay: &MemoryRelay,
18959        community: &CommunityV2,
18960        signer: &Keys,
18961        member: &PublicKey,
18962        role_ids: Vec<String>,
18963        version: u64,
18964        citation: Option<&crate::community::edition::AuthorityCitation>,
18965    ) {
18966        let group = control::ControlPlane::of(&community).write_group().unwrap();
18967        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
18968        let prev = head_hash_on_relay(relay, community, &eid).await;
18969        let grant = MemberGrant { member: member.to_hex(), role_ids };
18970        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
18971        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
18972        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
18973        relay.publish(&wrap, &community.relays).await.unwrap();
18974    }
18975
18976    #[tokio::test]
18977    async fn an_uncited_edition_cannot_self_promote_or_outlive_a_demotion() {
18978        // The two ways an ADD could escalate if the citation were the thing holding
18979        // the line. It is not — rank is — and rank is resolved against the CURRENT
18980        // roster, which is precisely what the spec says the verifier must do.
18981        let (bed, owner, admin) = TestBed::new();
18982        bed.swap_to(&owner);
18983        let community = create_community(&bed.relay, "NoEscalate", bed.relays.clone(), None).await.unwrap();
18984        let admin_pk = admin.keys.public_key();
18985
18986        // An admin at position 3, and a role ABOVE them at position 1.
18987        let mid = "d1".repeat(32);
18988        let mut mid_role = admin_role(&mid, Permissions::admin().0);
18989        mid_role.position = 3;
18990        publish_role(&bed.relay, &community, &owner.keys, &mid_role, 1).await;
18991        let high = "d2".repeat(32);
18992        let mut high_role = admin_role(&high, Permissions::admin().0);
18993        high_role.position = 1;
18994        publish_role(&bed.relay, &community, &owner.keys, &high_role, 1).await;
18995        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![mid.clone()], 1).await;
18996
18997        // 1. SELF-PROMOTION, uncited: the admin grants THEMSELVES the higher role.
18998        publish_grant_citing(&bed.relay, &community, &admin.keys, &admin_pk, vec![high.clone(), mid.clone()], 2, None).await;
18999        // Assert the PROPERTY, not the shape: the role must not be held, whether the
19000        // forged edition was refused outright or folded back to the owner's version.
19001        let view = fetch_authority(&bed.relay, &community).await;
19002        assert!(
19003            !view.roles.grants.iter().any(|g| g.member == admin_pk.to_hex() && g.role_ids.contains(&high)),
19004            "an uncited edition must not let an author grant themselves a role they do not \
19005             outrank — you cannot act at or above your own rank, citation or not",
19006        );
19007
19008        // 2. OUTLIVING A DEMOTION, uncited: the owner demotes the admin to nothing,
19009        //    and the admin's uncited grant to a bystander must stop being honored the
19010        //    moment this client folds the demotion.
19011        let bystander = Keys::generate().public_key();
19012        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![mid.clone()], 1, None).await;
19013        let before = fetch_authority(&bed.relay, &community).await;
19014        assert!(
19015            !before.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19016            "an admin at position 3 cannot grant position 3 — equal cannot act on equal",
19017        );
19018
19019        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![], 3).await;
19020        let after = fetch_authority(&bed.relay, &community).await;
19021        assert!(
19022            !after.roles.is_admin(&admin_pk.to_hex()),
19023            "the owner's demotion folds",
19024        );
19025        assert!(
19026            !after.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19027            "and nothing the demoted author wrote is honored afterwards — authority is \
19028             resolved against the CURRENT roster, which is what the citation was only ever \
19029             a scheduling hint for",
19030        );
19031    }
19032
19033    #[tokio::test]
19034    async fn an_uncited_edition_folds_on_rank_alone_and_never_escalates() {
19035        // CORD-04 §5 calls the `vac` a SYNC FLOOR, not the verdict: a verifier waits
19036        // until it has synced the cited Grant, then resolves rank against its CURRENT
19037        // roster. Refusing an edition that cites NOTHING promoted that sync aid into an
19038        // authority requirement — and pre-citation clients emitted no `vac` at all, so
19039        // whole live rosters folded to nothing while a lenient implementation read the
19040        // same plane fine.
19041        //
19042        // What actually prevents escalation is rank, and rank is unchanged. This pins
19043        // both halves: a genuine in-rank edition folds without a citation, and an
19044        // out-of-rank one does not, cited or otherwise.
19045        let (bed, owner, admin) = TestBed::new();
19046        bed.swap_to(&owner);
19047        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
19048        let admin_pk = admin.keys.public_key();
19049        let rid = "c3".repeat(32);
19050        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
19051        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
19052
19053        // A role BENEATH the admin (they sit at position 1).
19054        let low_rid = "c4".repeat(32);
19055        let mut low = admin_role(&low_rid, Permissions::admin().0);
19056        low.position = 5;
19057        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
19058
19059        // 1. GENUINE and in-rank, citing nothing → folds.
19060        let bystander = Keys::generate().public_key();
19061        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
19062        let view = fetch_authority(&bed.relay, &community).await;
19063        assert!(
19064            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19065            "an uncited edition from an author the roster DOES authorize, acting beneath \
19066             their own rank, is genuine and must fold",
19067        );
19068        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant still folds");
19069
19070        // 2. ESCALATION, uncited: a stranger the roster authorizes for nothing.
19071        let stranger = Keys::generate();
19072        let victim = Keys::generate().public_key();
19073        publish_grant_citing(&bed.relay, &community, &stranger, &victim, vec![low_rid.clone()], 1, None).await;
19074        let view = fetch_authority(&bed.relay, &community).await;
19075        assert!(
19076            !view.roles.grants.iter().any(|g| g.member == victim.to_hex()),
19077            "an uncited edition from an UNAUTHORIZED author must never fold — dropping the \
19078             citation requirement must not become a way in",
19079        );
19080
19081        // 3. ESCALATION, uncited, by a real admin acting AT their own rank: equal
19082        //    cannot act on equal, citation or not.
19083        let peer = Keys::generate().public_key();
19084        publish_grant_citing(&bed.relay, &community, &admin.keys, &peer, vec![rid.clone()], 1, None).await;
19085        let view = fetch_authority(&bed.relay, &community).await;
19086        assert!(
19087            !view.roles.grants.iter().any(|g| g.member == peer.to_hex()),
19088            "an authorized author still cannot grant AT their own rank — rank is what \
19089             stops escalation, and it is untouched",
19090        );
19091
19092        // 4. ESCALATION, uncited REVOCATION — the case that makes the citation
19093        //    load-bearing. A strip names no roles, so the rank gate has no position to
19094        //    check and passes vacuously; the citation is the only thing standing there.
19095        //    An uncited edition may ADD authority, never remove it.
19096        publish_grant_citing(&bed.relay, &community, &stranger, &bystander, vec![], 9, None).await;
19097        let view = fetch_authority(&bed.relay, &community).await;
19098        assert!(
19099            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
19100            "an uncited empty grant must NOT strip a member — a revocation's rank check is \
19101             vacuous, so tolerating it uncited hands any stranger a demotion primitive",
19102        );
19103    }
19104
19105    #[tokio::test]
19106    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
19107        // The invite is deliberately ungated, so a fresh Join can arrive seconds
19108        // BEFORE the unban edition. It must reach the store (banned = a fold
19109        // verdict, not a storage verdict) so the unban resurrects the member —
19110        // dropped at ingest, they stayed invisible forever.
19111        let (bed, owner, member) = TestBed::new();
19112        bed.swap_to(&owner);
19113        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
19114        let member_pk = member.keys.public_key();
19115        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19116
19117        // Locally banned (edition folded at t=1000s), with the outliving mark.
19118        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
19119        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
19120
19121        // Their Join lands 60s after the ban mark, while the banlist still says banned.
19122        let join = guestbook::GuestbookEvent { epoch: None, observed_at: None,
19123            rumor_id: [9u8; 32],
19124            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
19125        };
19126        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
19127        assert!(
19128            !stored_memberlist(&community).unwrap().contains(&member_pk),
19129            "while banned, the fold keeps them out"
19130        );
19131
19132        // The unban folds: same store, no refetch needed — the Join resurrects them.
19133        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
19134        assert!(
19135            stored_memberlist(&community).unwrap().contains(&member_pk),
19136            "after the unban the raced Join makes them a member again"
19137        );
19138    }
19139
19140    #[tokio::test]
19141    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
19142        // The ban→unban race: a Ban's refound buries the old root over several
19143        // publishes while a concurrently-issued command still holds the
19144        // pre-commit struct. That unban used to land on the buried control
19145        // plane — "succeeding" while no reader would ever fold it — and a
19146        // concurrently-minted invite stranded its joiner on the dead epoch.
19147        let (bed, owner, member) = TestBed::new();
19148        bed.swap_to(&owner);
19149        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
19150        let member_pk = member.keys.public_key();
19151
19152        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
19153        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
19154
19155        // The stale-struct unban is REFUSED (retryable), never misdirected.
19156        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
19157        assert!(err.contains("re-founded"), "unban: {err}");
19158        // A stale invite must not mint dead-epoch key material.
19159        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
19160        assert!(err.contains("re-founded"), "invite: {err}");
19161        // Neither is a kick allowed to ride the buried guestbook.
19162        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
19163        assert!(err.contains("re-founded"), "kick: {err}");
19164
19165        // The retry path: a fresh load lands the unban on the LIVING plane.
19166        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19167        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
19168        let view = fetch_authority(&bed.relay, &fresh).await;
19169        assert!(view.banned.is_empty(), "the retried unban actually unbans");
19170    }
19171
19172    /// [`MemoryRelay`] with a cooperative yield before every call, so two units
19173    /// driven by one `join!` genuinely interleave at each network boundary — on
19174    /// the single-threaded test runtime the bare relay resolves without yielding,
19175    /// the first unit runs to completion, and the race never happens.
19176    struct YieldyRelay<'a>(&'a MemoryRelay);
19177    #[async_trait::async_trait]
19178    impl Transport for YieldyRelay<'_> {
19179        async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
19180            crate::rt::yield_now().await;
19181            self.0.publish(event, relays).await
19182        }
19183        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
19184            crate::rt::yield_now().await;
19185            self.0.fetch(query, relays).await
19186        }
19187        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
19188            crate::rt::yield_now().await;
19189            self.0.fetch_plane(plane, query, relays).await
19190        }
19191        async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
19192            crate::rt::yield_now().await;
19193            self.0.publish_durable(event, relays).await
19194        }
19195    }
19196
19197    #[tokio::test]
19198    async fn concurrent_bans_compose_instead_of_erasing_each_other() {
19199        // The SDK spawns a handler task per message, so a spam wave runs N ban
19200        // units concurrently. Unserialized, both read the pre-sibling banlist,
19201        // the second head erases the first target at every reader, and the
19202        // refound compacts that erasure into the new epoch permanently.
19203        let (bed, owner, _member) = TestBed::new();
19204        bed.swap_to(&owner);
19205        let community = create_community(&bed.relay, "Wave", bed.relays.clone(), None).await.unwrap();
19206        let a = Keys::generate().public_key();
19207        let b = Keys::generate().public_key();
19208
19209        let yieldy = YieldyRelay(&bed.relay);
19210        let (wave_a, wave_b) = ([a], [b]);
19211        let (ra, rb) = tokio::join!(
19212            set_members_banned(&yieldy, community.id(), &wave_a, true),
19213            set_members_banned(&yieldy, community.id(), &wave_b, true),
19214        );
19215        ra.unwrap();
19216        rb.unwrap();
19217
19218        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19219        assert_eq!(fresh.root_epoch, Epoch(2), "each single ban still performed its own read cut");
19220        let view = fetch_authority(&bed.relay, &fresh).await;
19221        assert!(
19222            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
19223            "both racing bans landed: {:?}",
19224            view.banned
19225        );
19226    }
19227
19228    #[tokio::test]
19229    async fn bans_stacked_behind_a_rotation_coalesce_into_one_read_cut() {
19230        // Human-speed serial bans: the leader's read cut holds the lock for a
19231        // whole rotation, so bans arriving meanwhile deposit intents. The next
19232        // lock holder drains ALL of them — one edition, one rotation, however
19233        // many stacked. Worst case for a burst of N is 2 rotations, never N.
19234        let (bed, owner, _member) = TestBed::new();
19235        bed.swap_to(&owner);
19236        let community = create_community(&bed.relay, "Stack", bed.relays.clone(), None).await.unwrap();
19237        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19238        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19239        let (a, b, c, d) = (
19240            Keys::generate().public_key(),
19241            Keys::generate().public_key(),
19242            Keys::generate().public_key(),
19243            Keys::generate().public_key(),
19244        );
19245
19246        let yieldy = YieldyRelay(&bed.relay);
19247        let (wa, wb, wc, wd) = ([a], [b], [c], [d]);
19248        // The first future drains itself and rotates; the other three stack
19249        // behind that rotation and coalesce under the next leader.
19250        let (ra, rb, rc, rd) = tokio::join!(
19251            set_members_banned(&yieldy, community.id(), &wa, true),
19252            set_members_banned(&yieldy, community.id(), &wb, true),
19253            set_members_banned(&yieldy, community.id(), &wc, true),
19254            set_members_banned(&yieldy, community.id(), &wd, true),
19255        );
19256        ra.unwrap();
19257        rb.unwrap();
19258        rc.unwrap();
19259        rd.unwrap();
19260
19261        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19262        assert_eq!(fresh.root_epoch, Epoch(2), "leader rotation + ONE coalesced rotation, never four");
19263        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19264        assert_eq!(version, 2, "two editions total: the leader's and the coalesced drain's");
19265        let view = fetch_authority(&bed.relay, &fresh).await;
19266        for pk in [a, b, c, d] {
19267            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
19268        }
19269    }
19270
19271    #[tokio::test]
19272    async fn a_stacked_ban_and_unban_coalesce_in_arrival_order() {
19273        // The drain applies intents in arrival order over the evolving list, so
19274        // ban(X) … unban(X) stacked behind one rotation nets X out entirely and
19275        // only the net newly-banned member costs a read cut.
19276        let (bed, owner, _member) = TestBed::new();
19277        bed.swap_to(&owner);
19278        let community = create_community(&bed.relay, "Net", bed.relays.clone(), None).await.unwrap();
19279        let x = Keys::generate().public_key();
19280        let y = Keys::generate().public_key();
19281
19282        let yieldy = YieldyRelay(&bed.relay);
19283        let (wx, wy) = ([x], [y]);
19284        let (rx1, ry, rx2) = tokio::join!(
19285            set_members_banned(&yieldy, community.id(), &wx, true),  // leader: rotates on X
19286            set_members_banned(&yieldy, community.id(), &wy, true),  // stacked
19287            set_members_banned(&yieldy, community.id(), &wx, false), // stacked: nets X back out
19288        );
19289        rx1.unwrap();
19290        ry.unwrap();
19291        rx2.unwrap();
19292
19293        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19294        assert_eq!(fresh.root_epoch, Epoch(2), "X's rotation + one coalesced rotation for Y");
19295        let view = fetch_authority(&bed.relay, &fresh).await;
19296        assert!(!view.banned.contains(&x.to_hex()), "the stacked unban netted X out");
19297        assert!(view.banned.contains(&y.to_hex()), "Y's stacked ban landed");
19298    }
19299
19300    #[tokio::test]
19301    async fn a_second_member_folds_every_stacked_ban() {
19302        // Derek's observer divergence, inverted: the whole point of composing the
19303        // batch correctly is that an INDEPENDENT reader folding the same plane
19304        // agrees. A fresh joiner (separate account DB, post-rotation bundle) must
19305        // see every stacked ban — not just the leader's own echo.
19306        let (bed, owner, member) = TestBed::new();
19307        bed.swap_to(&owner);
19308        let community = create_community(&bed.relay, "Agree", bed.relays.clone(), None).await.unwrap();
19309        let a = Keys::generate().public_key();
19310        let b = Keys::generate().public_key();
19311
19312        let yieldy = YieldyRelay(&bed.relay);
19313        let (wa, wb) = ([a], [b]);
19314        let (ra, rb) = tokio::join!(
19315            set_members_banned(&yieldy, community.id(), &wa, true),
19316            set_members_banned(&yieldy, community.id(), &wb, true),
19317        );
19318        ra.unwrap();
19319        rb.unwrap();
19320
19321        // Post-rotation bundle, exactly what a live link would serve now.
19322        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19323        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19324        let bundle_json = serde_json::to_string(&bundle).unwrap();
19325
19326        bed.swap_to(&member);
19327        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19328        let _ = follow_control(&bed.relay, &joined).await;
19329        let view = fetch_authority(&bed.relay, &joined).await;
19330        assert!(
19331            view.banned.contains(&a.to_hex()) && view.banned.contains(&b.to_hex()),
19332            "an independent reader folds BOTH stacked bans: {:?}",
19333            view.banned
19334        );
19335    }
19336
19337    #[tokio::test]
19338    async fn a_net_zero_batch_publishes_no_edition() {
19339        // ban(X) + unban(X) stacked in one drain nets to the original list — the
19340        // batch must not spend an edition (or a rotation) publishing a no-op.
19341        let (bed, owner, _member) = TestBed::new();
19342        bed.swap_to(&owner);
19343        let community = create_community(&bed.relay, "Noop", bed.relays.clone(), None).await.unwrap();
19344        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19345        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19346        let x = Keys::generate().public_key();
19347
19348        // The leader's slot is taken by a harmless real ban so X's ban+unban land
19349        // in ONE coalesced drain together.
19350        let decoy = Keys::generate().public_key();
19351        let yieldy = YieldyRelay(&bed.relay);
19352        let (wd, wx) = ([decoy], [x]);
19353        let (rd, r1, r2) = tokio::join!(
19354            set_members_banned(&yieldy, community.id(), &wd, true),
19355            set_members_banned(&yieldy, community.id(), &wx, true),
19356            set_members_banned(&yieldy, community.id(), &wx, false),
19357        );
19358        rd.unwrap();
19359        r1.unwrap();
19360        r2.unwrap();
19361
19362        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19363        assert_eq!(fresh.root_epoch, Epoch(1), "only the decoy's rotation — the net-zero drain rotated nothing");
19364        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19365        assert_eq!(version, 1, "only the decoy's edition — the net-zero drain published nothing");
19366        let view = fetch_authority(&bed.relay, &fresh).await;
19367        assert!(!view.banned.contains(&x.to_hex()));
19368        assert!(view.banned.contains(&decoy.to_hex()));
19369    }
19370
19371    /// Fetches fine, refuses every publish — the dead-network shape.
19372    struct BrokenPublish<'a>(&'a MemoryRelay);
19373    #[async_trait::async_trait]
19374    impl Transport for BrokenPublish<'_> {
19375        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
19376            crate::rt::yield_now().await;
19377            Err("relay refused".to_string())
19378        }
19379        async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
19380            crate::rt::yield_now().await;
19381            self.0.fetch(query, relays).await
19382        }
19383        async fn fetch_plane(&self, plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> {
19384            crate::rt::yield_now().await;
19385            self.0.fetch_plane(plane, query, relays).await
19386        }
19387        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
19388            crate::rt::yield_now().await;
19389            Err("relay refused".to_string())
19390        }
19391    }
19392
19393    #[tokio::test]
19394    async fn a_failed_publish_answers_every_stacked_caller_and_never_hangs() {
19395        // The batch's shared fate: when the edition can't land, every stacked
19396        // intent must resolve with the error — a waiter left parked on a dead
19397        // oneshot would hang a moderation bot forever.
19398        let (bed, owner, _member) = TestBed::new();
19399        bed.swap_to(&owner);
19400        let community = create_community(&bed.relay, "Dead", bed.relays.clone(), None).await.unwrap();
19401        let a = Keys::generate().public_key();
19402        let b = Keys::generate().public_key();
19403
19404        let broken = BrokenPublish(&bed.relay);
19405        let (wa, wb) = ([a], [b]);
19406        let (ra, rb) = crate::rt::time::timeout(
19407            std::time::Duration::from_secs(30),
19408            async {
19409                tokio::join!(
19410                    set_members_banned(&broken, community.id(), &wa, true),
19411                    set_members_banned(&broken, community.id(), &wb, true),
19412                )
19413            },
19414        )
19415        .await
19416        .expect("stacked callers must resolve, never hang");
19417        assert!(ra.is_err(), "the leader surfaced the publish failure");
19418        assert!(rb.is_err(), "the stacked caller surfaced it too");
19419
19420        // Nothing landed: a later fold shows no bans.
19421        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19422        let view = fetch_authority(&bed.relay, &fresh).await;
19423        assert!(view.banned.is_empty(), "no half-published state: {:?}", view.banned);
19424        assert_eq!(fresh.root_epoch, Epoch(0), "no rotation on a failed edition");
19425    }
19426
19427    #[tokio::test]
19428    async fn a_poison_intent_fails_alone_not_its_batchmates() {
19429        // Per-intent verdicts: an add that busts the 500 ceiling answers ITS
19430        // caller with the refusal while its batch-mates publish normally.
19431        let (bed, owner, _member) = TestBed::new();
19432        bed.swap_to(&owner);
19433        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
19434        // Pre-fill to 498 so the leader lands 499, the first stacked ban lands
19435        // exactly 500, and the second stacked ban would be 501.
19436        let filler: Vec<String> = (0..498).map(|i| format!("{i:064x}")).collect();
19437        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19438        set_banlist(&bed.relay, &held, &filler).await.unwrap();
19439        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19440        crate::db::community::set_community_banlist(&cid_hex, &filler, 1).unwrap();
19441
19442        let (a, x, y) = (Keys::generate().public_key(), Keys::generate().public_key(), Keys::generate().public_key());
19443        let yieldy = YieldyRelay(&bed.relay);
19444        let (wa, wx, wy) = ([a], [x], [y]);
19445        let (ra, rx, ry) = tokio::join!(
19446            set_members_banned(&yieldy, community.id(), &wa, true),
19447            set_members_banned(&yieldy, community.id(), &wx, true),
19448            set_members_banned(&yieldy, community.id(), &wy, true),
19449        );
19450        ra.unwrap();
19451        rx.unwrap();
19452        let err = ry.unwrap_err();
19453        assert!(err.contains("ceiling"), "the over-cap intent failed alone: {err}");
19454
19455        let list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19456        assert_eq!(list.len(), 500, "the batch published up to the ceiling");
19457        assert!(list.contains(&x.to_hex()) && !list.contains(&y.to_hex()));
19458    }
19459
19460    #[tokio::test]
19461    async fn ban_many_is_one_edition_one_refound_for_the_whole_wave() {
19462        let (bed, owner, _member) = TestBed::new();
19463        bed.swap_to(&owner);
19464        let community = create_community(&bed.relay, "Purge", bed.relays.clone(), None).await.unwrap();
19465        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19466        let banlist_hex = crate::simd::hex::bytes_to_hex_32(&super::super::derive::banlist_locator(community.id()));
19467        let wave: Vec<PublicKey> = (0..3).map(|_| Keys::generate().public_key()).collect();
19468
19469        set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap();
19470
19471        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19472        assert_eq!(fresh.root_epoch, Epoch(1), "one refound for the whole wave, not one per target");
19473        let (version, _) = crate::db::community::get_edition_head(&cid_hex, &banlist_hex).unwrap().unwrap();
19474        assert_eq!(version, 1, "one banlist edition carried the whole wave");
19475        let view = fetch_authority(&bed.relay, &fresh).await;
19476        for pk in &wave {
19477            assert!(view.banned.contains(&pk.to_hex()), "{} missing from the fold", pk.to_hex());
19478        }
19479
19480        // The reductive mirror: unban two of three in one edition, no read cut.
19481        set_members_banned(&bed.relay, community.id(), &wave[..2], false).await.unwrap();
19482        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19483        assert_eq!(fresh.root_epoch, Epoch(1), "an unban rotates nothing");
19484        let view = fetch_authority(&bed.relay, &fresh).await;
19485        assert_eq!(view.banned.len(), 1, "reductive: exactly the un-unbanned target remains");
19486        assert!(view.banned.contains(&wave[2].to_hex()));
19487    }
19488
19489    #[tokio::test]
19490    async fn a_batch_past_the_banlist_cap_refuses_before_publishing() {
19491        let (bed, owner, _member) = TestBed::new();
19492        bed.swap_to(&owner);
19493        let community = create_community(&bed.relay, "Cap", bed.relays.clone(), None).await.unwrap();
19494        let wave: Vec<PublicKey> = (0..=crate::community::v2::roles::MAX_BANLIST).map(|_| Keys::generate().public_key()).collect();
19495
19496        let err = set_members_banned(&bed.relay, community.id(), &wave, true).await.unwrap_err();
19497        assert!(err.contains("ceiling"), "refused at the cap: {err}");
19498        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19499        assert_eq!(fresh.root_epoch, Epoch(0), "nothing rotated");
19500        let view = fetch_authority(&bed.relay, &fresh).await;
19501        assert!(view.banned.is_empty(), "nothing published");
19502    }
19503
19504    #[tokio::test]
19505    async fn an_uncited_kick_from_an_admin_is_not_honored() {
19506        // CORD-04 §5: a non-owner authority action must name the Grant it acts
19507        // under, and the reader refuses until it holds that Grant. Emitting the
19508        // `vac` without checking it buys nothing — a demoted admin's kick would
19509        // still land on any client that hadn't synced the demotion.
19510        let (bed, owner, member) = TestBed::new();
19511        bed.swap_to(&owner);
19512        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
19513        let admin = Keys::generate();
19514        let member_pk = member.keys.public_key();
19515        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
19516
19517        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19518        let view = fetch_authority(&bed.relay, &community).await;
19519        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
19520
19521        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
19522        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
19523        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
19524        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
19525
19526        let joined = guestbook::GuestbookEvent { epoch: None, observed_at: None,
19527            rumor_id: [1u8; 32],
19528            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
19529        };
19530        let kick = |citation, id: u8, at| guestbook::GuestbookEvent { epoch: None, observed_at: None,
19531            rumor_id: [id; 32],
19532            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
19533        };
19534        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19535        let empty_bans = std::collections::BTreeSet::new();
19536        let empty_marks = std::collections::BTreeMap::new();
19537        let fold = |evs: &[guestbook::GuestbookEvent]| {
19538            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
19539        };
19540
19541        assert!(
19542            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
19543            "an uncited kick from an admin is not honored"
19544        );
19545        assert!(
19546            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
19547            "the same kick WITH its synced citation removes them"
19548        );
19549    }
19550
19551    #[tokio::test]
19552    async fn kicking_an_admin_strips_their_roles_first() {
19553        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
19554        // strip leaves the target out of the memberlist but still holding every
19555        // management bit, so every client keeps honoring their control editions.
19556        let (bed, owner, member) = TestBed::new();
19557        bed.swap_to(&owner);
19558        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
19559        let member_pk = member.keys.public_key();
19560        let member_hex = member_pk.to_hex();
19561        let owner_hex = owner.keys.public_key().to_hex();
19562
19563        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19564        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
19565
19566        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
19567
19568        let view = fetch_authority(&bed.relay, &community).await;
19569        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
19570        assert!(
19571            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
19572            "a kicked admin holds no bit"
19573        );
19574        assert!(
19575            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
19576            "and the directive still removed them"
19577        );
19578    }
19579
19580    #[tokio::test]
19581    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
19582        let (bed, owner, member) = TestBed::new();
19583        bed.swap_to(&owner);
19584        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
19585        let member_pk = member.keys.public_key();
19586        let member_hex = member_pk.to_hex();
19587        let owner_hex = owner.keys.public_key().to_hex();
19588
19589        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19590        let view = fetch_authority(&bed.relay, &community).await;
19591        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
19592        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
19593
19594        // A second grant (any device) converges on the SAME role entity — and a
19595        // repeat is a no-op, not a version bump.
19596        let second = Keys::generate().public_key();
19597        grant_admin(&bed.relay, &community, &second).await.unwrap();
19598        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19599        let view = fetch_authority(&bed.relay, &community).await;
19600        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
19601        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
19602        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
19603        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
19604
19605        // Revoke strips ONLY the admin role and de-authorizes.
19606        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
19607        let view = fetch_authority(&bed.relay, &community).await;
19608        assert!(!view.roles.is_admin(&member_hex), "revoked");
19609        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
19610        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
19611    }
19612
19613    #[tokio::test]
19614    async fn follow_control_persists_the_roster_for_sync_local_reads() {
19615        let (bed, owner, member) = TestBed::new();
19616        bed.swap_to(&owner);
19617        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
19618        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19619        let member_hex = member.keys.public_key().to_hex();
19620        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19621
19622        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
19623        follow_control(&bed.relay, &community).await.unwrap().updated;
19624        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19625        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
19626
19627        // A withholding relay serves nothing — an empty fold raises no gap flag, and
19628        // the stored roster must be RETAINED, never wiped.
19629        let withholding = MemoryRelay::new();
19630        let _ = follow_control(&withholding, &community).await;
19631        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19632        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
19633
19634        // A real revocation (a NEWER grant edition) does replace it.
19635        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19636        follow_control(&bed.relay, &community).await.unwrap().updated;
19637        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19638        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
19639    }
19640
19641    #[tokio::test]
19642    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
19643        let (bed, owner, member) = TestBed::new();
19644        bed.swap_to(&owner);
19645        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
19646
19647        bed.swap_to(&member);
19648        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
19649        assert!(err.contains("owner"), "refused before any publish: {err}");
19650
19651        // The deterministic admin-role entity stays unsquatted — the owner's later
19652        // legitimate mint is version 1 and folds cleanly.
19653        bed.swap_to(&owner);
19654        let view = fetch_authority(&bed.relay, &community).await;
19655        assert!(view.roles.roles.is_empty(), "no role edition landed");
19656        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
19657        let view = fetch_authority(&bed.relay, &community).await;
19658        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
19659    }
19660
19661    #[tokio::test]
19662    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
19663        let (bed, owner, member) = TestBed::new();
19664        bed.swap_to(&owner);
19665        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
19666        let member_pk = member.keys.public_key();
19667
19668        // The member already holds a Mod role, granted through the real send path
19669        // (so this device's floors track both entities).
19670        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
19671        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
19672        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
19673
19674        // A relay that withholds the control plane must refuse the merge — a blind
19675        // push would erase the Mod role at a higher version.
19676        let withholding = MemoryRelay::new();
19677        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
19678        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
19679
19680        // Against the full relay the merge preserves the Mod role.
19681        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
19682        let view = fetch_authority(&bed.relay, &community).await;
19683        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
19684        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
19685        assert!(grant.role_ids.contains(&mod_rid));
19686    }
19687
19688    #[tokio::test]
19689    async fn fetch_authority_reflects_a_granted_admin() {
19690        let (bed, owner, member) = TestBed::new();
19691        bed.swap_to(&owner);
19692        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
19693        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
19694        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
19695        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
19696
19697        let view = fetch_authority(&bed.relay, &community).await;
19698        let member_hex = member.keys.public_key().to_hex();
19699        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
19700        assert!(
19701            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
19702            "an ADMIN_ALL grant carries KICK"
19703        );
19704        assert!(view.banned.is_empty());
19705    }
19706
19707    // ── Pins (CORD-04 §7) — fold, authority, and the silent Admin widening ──
19708
19709    /// The full wire round trip: a real message pinned into a published
19710    /// edition, folded by the control follow, persisted, and read back proven.
19711    #[tokio::test]
19712    async fn a_pin_edition_folds_persists_and_reads_back() {
19713        let (_tmp, _guard, _owner) = init_test_db();
19714        let relay = MemoryRelay::new();
19715        let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
19716        let general = community.channels[0].id;
19717        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19718
19719        send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
19720        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19721        let opened = page
19722            .iter()
19723            .find_map(|f| match &f.event {
19724                ChatEvent::Message { opened, .. } => Some(opened.clone()),
19725                _ => None,
19726            })
19727            .expect("the sent message reads back");
19728
19729        let ch = community.channels[0].clone();
19730        let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
19731        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19732        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19733
19734        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19735        publish_control_edition(&relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19736        follow_control(&relay, &community).await.unwrap().updated;
19737
19738        let read = read_channel_pins(&community, &general).unwrap();
19739        assert!(!read.sealed);
19740        assert!(read.version >= 1, "the folded head persisted");
19741        assert_eq!(read.pins.len(), 1);
19742        assert_eq!(read.pins[0].content, "pin-worthy");
19743        assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
19744    }
19745
19746    #[tokio::test]
19747    async fn a_fresh_join_sees_pins_and_the_banlist_without_waiting_for_a_follow() {
19748        // The join's verification walk already folds the control plane, so pins and
19749        // the banlist must persist FROM THE JOIN — deferring them to the follow
19750        // worker's queued re-walk left a fresh member pins-blind (and rendering a
19751        // banned author) for the whole queue delay.
19752        let (bed, owner, member) = TestBed::new();
19753        bed.swap_to(&owner);
19754        let community = create_community(&bed.relay, "PinJoin", bed.relays.clone(), None).await.unwrap();
19755        let general = community.channels[0].id;
19756        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19757
19758        send_message(&bed.relay, &community, &general, "pin-worthy").await.unwrap();
19759        let page = fetch_channel(&bed.relay, &community, &general, 10).await.unwrap();
19760        let opened = page
19761            .iter()
19762            .find_map(|f| match &f.event {
19763                ChatEvent::Message { opened, .. } => Some(opened.clone()),
19764                _ => None,
19765            })
19766            .expect("the sent message reads back");
19767        let ch = community.channels[0].clone();
19768        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19769        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19770        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
19771        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19772        publish_control_edition(&bed.relay, &community, vsk::PINS, &eid, &content).await.unwrap();
19773        let spammer = Keys::generate().public_key().to_hex();
19774        set_banlist(&bed.relay, &community, std::slice::from_ref(&spammer)).await.unwrap();
19775        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
19776        let bundle_json = serde_json::to_string(&bundle).unwrap();
19777
19778        bed.swap_to(&member);
19779        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
19780        // Deliberately NO follow_control here — the join alone must suffice.
19781        let read = read_channel_pins(&joined, &general).unwrap();
19782        assert!(!read.sealed);
19783        assert_eq!(read.pins.len(), 1, "pins visible the instant the join returns");
19784        assert_eq!(read.pins[0].content, "pin-worthy");
19785        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
19786        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap();
19787        assert!(banlist.contains(&spammer), "the banlist is enforced from the join, not the first follow");
19788    }
19789
19790    /// CORD-04 §5: a pins edition from an author holding no PIN_MESSAGES never
19791    /// becomes the head — the fold's authority gate covers the new entity.
19792    #[tokio::test]
19793    async fn an_unauthorized_pin_edition_never_folds() {
19794        let (_tmp, _guard, _owner) = init_test_db();
19795        let relay = MemoryRelay::new();
19796        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
19797        let general = community.channels[0].id;
19798
19799        let rogue = Keys::generate();
19800        let group = control::ControlPlane::of(&community).write_group().unwrap();
19801        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
19802        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
19803        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
19804        relay.publish(&wrap, &community.relays).await.unwrap();
19805
19806        follow_control(&relay, &community).await.unwrap().updated;
19807        let read = read_channel_pins(&community, &general).unwrap();
19808        assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
19809        assert!(read.pins.is_empty());
19810    }
19811
19812    /// The silent owner-side widening: a pre-pins Admin role (founding mask)
19813    /// gains PIN_MESSAGES as one edition of the same entity; idempotent after.
19814    #[tokio::test]
19815    async fn owner_silently_widens_a_legacy_admin_role() {
19816        let (_tmp, _guard, owner) = init_test_db();
19817        let relay = MemoryRelay::new();
19818        let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
19819        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19820        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
19821
19822        // An Admin role exactly as a pre-pins build published it.
19823        let legacy = Role {
19824            role_id: rid.clone(),
19825            name: "Admin".into(),
19826            position: 1,
19827            permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
19828            scope: RoleScope::Server,
19829            color: 0, extra: Default::default(),
19830        };
19831        publish_role(&relay, &community, &owner, &legacy, 1).await;
19832        follow_control(&relay, &community).await.unwrap().updated;
19833
19834        assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
19835        follow_control(&relay, &community).await.unwrap().updated;
19836        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
19837        let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
19838        assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
19839        assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
19840
19841        // Second call: nothing left to widen.
19842        assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
19843    }
19844
19845    /// §7 deletion duty: the author-curator's own deleted message leaves the
19846    /// list as an immediate omitting edition.
19847    #[tokio::test]
19848    async fn the_deletion_duty_omits_a_pinned_message() {
19849        let (_tmp, _guard, _owner) = init_test_db();
19850        let relay = MemoryRelay::new();
19851        let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
19852        let general = community.channels[0].id;
19853        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19854
19855        let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
19856        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19857        let opened = page
19858            .iter()
19859            .find_map(|f| match &f.event {
19860                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19861                _ => None,
19862            })
19863            .unwrap();
19864        let ch = community.channels[0].clone();
19865        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19866        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19867        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19868        assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
19869
19870        // The author holds the bit (owner) → the duty publishes the omission at once.
19871        run_pin_duty(&relay, &ch_hex, &rumor_id, None).await.unwrap();
19872        let after = read_channel_pins(&community, &general).unwrap();
19873        assert!(after.pins.is_empty(), "the omitting edition landed");
19874        assert!(after.version >= 2, "a NEW edition, not a local erase");
19875    }
19876
19877    /// §7 edit duty: an edited pinned message gets its proof bundle refreshed,
19878    /// so keyless readers see the revision — and the duty is idempotent.
19879    #[tokio::test]
19880    async fn the_edit_duty_refreshes_a_pinned_proof() {
19881        let (_tmp, _guard, _owner) = init_test_db();
19882        let relay = MemoryRelay::new();
19883        let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
19884        let general = community.channels[0].id;
19885        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
19886
19887        let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
19888        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19889        let opened = page
19890            .iter()
19891            .find_map(|f| match &f.event {
19892                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19893                _ => None,
19894            })
19895            .unwrap();
19896        let ch = community.channels[0].clone();
19897        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
19898        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19899        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19900
19901        send_edit(&relay, &community, &general, &rumor_id, "second thoughts", &[]).await.unwrap();
19902        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19903        let edit_opened = page
19904            .iter()
19905            .find_map(|f| match &f.event {
19906                ChatEvent::Edit { opened, .. } => Some(opened.clone()),
19907                _ => None,
19908            })
19909            .expect("the edit reads back");
19910
19911        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone())).await.unwrap();
19912        let after = read_channel_pins(&community, &general).unwrap();
19913        assert_eq!(after.pins.len(), 1);
19914        assert_eq!(
19915            after.pins[0].content, "second thoughts",
19916            "the refreshed bundle proves the revision"
19917        );
19918        let v = after.version;
19919
19920        // Same revision again → monotonic guard, no new edition.
19921        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened)).await.unwrap();
19922        assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
19923    }
19924
19925    /// §7 author-side trigger: an own edit's relay echo is a dedup'd duplicate
19926    /// at ingest, so publish_chat's SEND echo must decide the duty itself —
19927    /// otherwise the author's own pin lags behind their message while every
19928    /// other client's follows ("the affected author acts at once").
19929    #[tokio::test]
19930    async fn an_own_edit_echo_carries_the_pin_duty_trigger() {
19931        let (_tmp, _guard, _owner) = init_test_db();
19932        let relay = MemoryRelay::new();
19933        let community = create_community(&relay, "OwnEdit", vec!["wss://r".into()], None).await.unwrap();
19934        let general = community.channels[0].id;
19935
19936        let rumor_id = send_message(&relay, &community, &general, "before").await.unwrap();
19937        send_edit(&relay, &community, &general, &rumor_id, "after", &[]).await.unwrap();
19938        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
19939        let edit_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Edit { .. })).expect("the edit reads back").event;
19940        let msg_event = &page.iter().find(|f| matches!(&f.event, ChatEvent::Message { .. })).expect("the message reads back").event;
19941
19942        let updated = crate::community::v2::inbound::ChatPersist::Updated {
19943            message: crate::types::Message::default(),
19944            edit_event: None,
19945        };
19946        let (target_hex, opened) = own_echo_pin_duty(&updated, edit_event).expect("an applied own edit fires the duty");
19947        assert_eq!(target_hex, rumor_id, "the duty targets the EDITED message, not the edit rumor");
19948        assert_ne!(opened.rumor_id.to_hex(), rumor_id, "and carries the edit's own opened stream for the bundle");
19949
19950        // A fresh message echo is not a duty…
19951        let new = crate::community::v2::inbound::ChatPersist::New(crate::types::Message::default());
19952        assert!(own_echo_pin_duty(&new, msg_event).is_none());
19953        // …and neither is a reaction-shaped Updated on a non-edit event.
19954        assert!(own_echo_pin_duty(&updated, msg_event).is_none());
19955    }
19956
19957    /// §7 Rotator duty: a private-channel rotation republishes the Pin List
19958    /// sealed under the NEW epoch — a member who joins after the rotation
19959    /// (holding only the new key) must not read the channel's pins as dark.
19960    #[tokio::test]
19961    async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
19962        let (_tmp, _guard, _owner) = init_test_db();
19963        let relay = MemoryRelay::new();
19964        let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
19965        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
19966        let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
19967        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
19968        let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
19969
19970        let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
19971        let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
19972        let opened = page
19973            .iter()
19974            .find_map(|f| match &f.event {
19975                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
19976                _ => None,
19977            })
19978            .unwrap();
19979        let ch = community.channel(&chan).unwrap().clone();
19980        let old_epoch = ch.epoch.0;
19981        let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
19982        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
19983        publish_pin_list(&relay, &community, &ch, &[entry]).await.unwrap();
19984        let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19985
19986        // Rotate the channel away from a (never-granted) member.
19987        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
19988        rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &[Keys::generate().public_key()])
19989            .await
19990            .unwrap();
19991
19992        // The stored head is a NEW edition, sealed under the NEW epoch.
19993        let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
19994        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
19995        assert_eq!(
19996            parsed["epoch"].as_str().unwrap(),
19997            (old_epoch + 1).to_string(),
19998            "the reseal names the rotated epoch"
19999        );
20000        assert!(version > v_before, "a real edition, not a local rewrite");
20001
20002        // The rotator's own post-rotation view still verifies the pin.
20003        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
20004        let read = read_channel_pins(&community, &chan).unwrap();
20005        assert!(!read.sealed);
20006        assert_eq!(read.pins.len(), 1);
20007        assert_eq!(read.pins[0].content, "sealed wisdom");
20008    }
20009
20010    /// The production ban-eraser: set_banlist must ECHO its published list
20011    /// into the local cache immediately. Before this, the cache moved only on
20012    /// a successful control fold — and a composing caller (ban = banlist →
20013    /// grant strip → refound) whose refound tripped re-read the stale list,
20014    /// so each of 19 real bans erased its predecessors.
20015    #[tokio::test]
20016    async fn a_published_banlist_echoes_locally_before_any_fold() {
20017        let (_tmp, _guard, _owner) = init_test_db();
20018        let relay = MemoryRelay::new();
20019        let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
20020        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
20021        let spammer_a = "aa".repeat(32);
20022        let spammer_b = "bb".repeat(32);
20023
20024        // Ban A. NO fold runs — the cache must hold the publish regardless.
20025        set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
20026        assert_eq!(
20027            crate::db::community::get_community_banlist(&cid_hex).unwrap(),
20028            vec![spammer_a.clone()],
20029            "the publish echoes without waiting for a fold"
20030        );
20031
20032        // Ban B composes from the cache, exactly as the SDK does.
20033        let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20034        list.push(spammer_b.clone());
20035        set_banlist(&relay, &community, &list).await.unwrap();
20036        let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20037        assert!(
20038            held.contains(&spammer_a) && held.contains(&spammer_b),
20039            "sequential bans UNION; the second must not erase the first: {held:?}"
20040        );
20041
20042        // The wire agrees: a real fold confirms rather than regresses.
20043        follow_control(&relay, &community).await.unwrap().updated;
20044        let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
20045        assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
20046    }
20047}