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, `SessionGuard`-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::{FinalizeEvent, FinalizeEventAsync, FinalizeUnsignedEvent};
15use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp};
16
17use super::super::transport::{Query, Transport};
18use super::super::{version, ChannelId, Epoch};
19use super::chat::{self, ChatEvent};
20use super::community::{ChannelV2, CommunityV2};
21use super::control;
22use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
23use super::invite::{self, CommunityInvite};
24use super::rekey::{self, Continuity, RekeyScope};
25use super::{guestbook, stream, vsk};
26use crate::community::edition::ParsedEdition;
27use crate::state::SessionGuard;
28use crate::ClientRelayExt;
29
30/// The active signer for v2 authority actions: the live client's signer — which
31/// covers a NIP-46 bunker / NIP-55 offline signer — falling back to the local
32/// vault keys when there is no client or no signer attached (local accounts,
33/// headless/CLI paths, and tests). Every v2 seal, rekey blob, and control edition
34/// signs / NIP-44-wraps through this, so a keyless account can create AND
35/// administer a community. v2's rekey locator is public + its blobs are pairwise
36/// NIP-44 (CORD-06 D1/D5), so unlike v1 there is no raw-ECDH exception.
37/// The active identity's public key for addressing/tags — authoritative (set at
38/// login), no signer round-trip. Used everywhere v2 needs "who am I" so a keyless
39/// account (empty vault) still resolves its own identity.
40fn me_pk() -> Result<PublicKey, String> {
41    crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
42}
43
44fn now_ms() -> u64 {
45    std::time::SystemTime::now()
46        .duration_since(std::time::UNIX_EPOCH)
47        .map(|d| d.as_millis() as u64)
48        .unwrap_or(0)
49}
50
51/// Create a fresh v2 community owned by the local identity: mint the genesis
52/// (self-certifying id + the two owner editions), persist, publish the genesis
53/// control editions, and announce the owner's Guestbook Join. Returns the saved
54/// community.
55pub async fn create_community<T: Transport + ?Sized>(
56    transport: &T,
57    name: &str,
58    relays: Vec<String>,
59    description: Option<String>,
60) -> Result<CommunityV2, String> {
61    let session = SessionGuard::capture();
62    let signer = crate::signer::active_signer()?;
63    let owner_pk = me_pk()?;
64    let at_ms = now_ms();
65
66    let meta = control::CommunityMetadata {
67        name: name.to_string(),
68        description: description.clone(),
69        relays: relays.clone(),
70        ..Default::default()
71    };
72    let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
73    let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
74
75    // Save-before-publish (like v1 create): no peers exist yet so there's no
76    // shared view to diverge from, and the fresh-random keys are irrecoverable
77    // if a publish hiccup rolled them back. Re-check the session after the genesis
78    // signing await (a bunker signs over the network) before the DB write.
79    if !session.is_valid() {
80        return Err("account changed during community creation".to_string());
81    }
82    // Seed the genesis edition heads (v1) as the owner's refuse-downgrade floor, so a
83    // later edit can't be rolled back by a relay serving only the genesis prefix. The
84    // live control sub is replay-free (limit 0), so the owner won't re-fold its own
85    // genesis to seed the floor otherwise. Floors land BEFORE the community row
86    // (floors-then-state ordering).
87    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
88    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
89    for wrap in &genesis.wraps {
90        if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
91            let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
92            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
93        }
94    }
95    crate::db::community::save_community_v2(&community)?;
96    // Archive the genesis root at epoch 0, so a later Refounding leaves this epoch's
97    // Public-channel history readable (CORD-03 §3 multi-epoch read).
98    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
99
100    // Publish the two genesis control editions at the epoch-0 control plane.
101    // Durable, not single-shot: over a slow transport (Tor) one attempt is a coin
102    // flip, and a lost genesis leaves a community that exists only locally. Durable
103    // races every relay, returns on the first ACK, then heals stragglers in the bg.
104    for wrap in &genesis.wraps {
105        transport.publish_durable(wrap, &community.relays).await?;
106    }
107
108    // Announce the owner's Guestbook Join so they appear in the memberlist. Relays are
109    // proven-alive by the genesis ACK above, so durable here just guarantees the owner's
110    // own join lands (member count) without a real block risk.
111    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
112    let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
113    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
114        let _ = transport.publish_durable(&join_wrap, &community.relays).await;
115    }
116
117    // Sync the new membership across devices (CORD-02 §8), durably — see the join path.
118    match republish_community_list(transport, Some(community.id())).await {
119        Ok(true) => {}
120        Ok(false) => republish_community_list_durable(Some(*community.id())),
121        Err(e) => {
122            crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
123            republish_community_list_durable(Some(*community.id()));
124        }
125    }
126    Ok(community)
127}
128
129/// Mint a v2 migration TWIN whose primary channel REUSES the v1 primary channel id (§migration)
130/// so chat history stitches through the flip. Same owner identity, fresh salt/root. Additional
131/// v1 channels are added by the wizard via `create_*_channel_with_id`. Mirrors
132/// [`create_community`]'s persist-before-publish + floor seeding.
133pub async fn create_migration_twin<T: Transport + ?Sized>(
134    transport: &T,
135    name: &str,
136    relays: Vec<String>,
137    description: Option<String>,
138    primary: (ChannelId, String),
139) -> Result<CommunityV2, String> {
140    let session = SessionGuard::capture();
141    let signer = crate::signer::active_signer()?;
142    let owner_pk = me_pk()?;
143    let at_ms = now_ms();
144
145    let meta = control::CommunityMetadata {
146        name: name.to_string(),
147        description: description.clone(),
148        relays: relays.clone(),
149        ..Default::default()
150    };
151    let primary_name = primary.1.clone();
152    let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
153        .await
154        .map_err(|e| e.to_string())?;
155    let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
156    // from_genesis hard-names the primary "general"; carry the v1 name (the wire edition
157    // already carries it, so this only keeps the owner's immediate local view correct).
158    if let Some(ch) = community.channels.first_mut() {
159        ch.name = primary_name;
160    }
161    if !session.is_valid() {
162        return Err("account changed during twin creation".to_string());
163    }
164    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
165    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
166    for wrap in &genesis.wraps {
167        if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
168            let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
169            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
170        }
171    }
172    crate::db::community::save_community_v2(&community)?;
173    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
174    for wrap in &genesis.wraps {
175        transport.publish_durable(wrap, &community.relays).await?;
176    }
177    // Owner Guestbook Join so they appear in the twin's memberlist.
178    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
179    let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
180    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
181        let _ = transport.publish_durable(&join_wrap, &community.relays).await;
182    }
183    Ok(community)
184}
185
186/// Clone a v1 banlist onto the v2 twin (§migration Phase 1.3): the join-time ban gate needs
187/// the v2 banlist to name every v1-banned npub, else a banned-but-never-cut member who can
188/// open `m` would walk in. Owner-signed on the twin's control plane.
189pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
190    transport: &T,
191    twin: &CommunityV2,
192    banned: &[String],
193) -> Result<(), String> {
194    if banned.is_empty() {
195        return Ok(());
196    }
197    set_banlist(transport, twin, banned).await
198}
199
200/// Clone v1 governance onto the twin (§migration Phase 1.3): every v1 member who was a FULL
201/// admin (effective permissions ⊇ ADMIN_ALL) is re-granted @admin on the twin (mapping v1's
202/// Admin onto v2's deterministic admin role id, CORD-04 §2). The owner is supreme by
203/// identity (never a grant) and banned members are skipped (a banned author's editions fold
204/// out anyway, and re-granting would spring them back to admin on a future unban).
205///
206/// NON-ESCALATION: only a full admin maps to v2 @admin (which holds ADMIN_ALL). A
207/// partial-management v1 role holder (e.g. CREATE_INVITE only — never minted by the v1 UI,
208/// but reachable via the SDK) degrades to a plain member rather than being ESCALATED to full
209/// admin. Bespoke non-admin custom roles are not carried — a documented, non-security gap.
210pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
211    transport: &T,
212    twin: &CommunityV2,
213    v1_roles: &crate::community::roles::CommunityRoles,
214    banned: &[String],
215) -> Result<(), String> {
216    use crate::community::roles::Permissions;
217    let owner = twin.owner()?;
218    for grant in &v1_roles.grants {
219        if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_ALL) {
220            continue; // not a full admin → plain member on v2 (never escalated)
221        }
222        if banned.contains(&grant.member) {
223            continue; // banned → no authority on v2, don't re-arm a future unban
224        }
225        let Ok(member) = PublicKey::parse(&grant.member) else { continue };
226        if member == owner {
227            continue; // supreme by identity — never needs a grant
228        }
229        grant_admin(transport, twin, &member).await?;
230    }
231    Ok(())
232}
233
234/// The twin's JoinMaterial — the membership subset sealed into the migration `m`.
235pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
236    join_material(twin)
237}
238
239/// Send a text message to a channel. Derives the channel's Chat-Plane group key
240/// (community_root for a Public channel, the channel key for a Private one),
241/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
242pub async fn send_message<T: Transport + ?Sized>(
243    transport: &T,
244    community: &CommunityV2,
245    channel_id: &ChannelId,
246    content: &str,
247) -> Result<String, String> {
248    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
249}
250
251/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
252/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
253/// attachments). Returns the message's rumor id (hex).
254pub async fn send_chat_message<T: Transport + ?Sized>(
255    transport: &T,
256    community: &CommunityV2,
257    channel_id: &ChannelId,
258    content: &str,
259    reply_to: Option<(&str, &str)>,
260    emoji: &[(&str, &str)],
261    extra_tags: Vec<nostr_sdk::prelude::Tag>,
262) -> Result<String, String> {
263    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
264}
265
266/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
267/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
268/// its optimistic pending row — the in-process echo and the finalize then key
269/// the SAME id (the exact v1 pending → sent contract).
270#[allow(clippy::too_many_arguments)]
271pub async fn send_chat_message_at<T: Transport + ?Sized>(
272    transport: &T,
273    community: &CommunityV2,
274    channel_id: &ChannelId,
275    content: &str,
276    reply_to: Option<(&str, &str)>,
277    emoji: &[(&str, &str)],
278    extra_tags: Vec<nostr_sdk::prelude::Tag>,
279    at_ms: u64,
280) -> Result<String, String> {
281    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
282    let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
283    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
284}
285
286/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
287/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
288/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
289/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
290#[allow(clippy::too_many_arguments)]
291pub async fn send_reaction<T: Transport + ?Sized>(
292    transport: &T,
293    community: &CommunityV2,
294    channel_id: &ChannelId,
295    target_id_hex: &str,
296    target_author_hex: &str,
297    target_kind: u16,
298    emoji_content: &str,
299    emoji: Option<(&str, &str)>,
300) -> Result<String, String> {
301    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
302    let at_ms = now_ms();
303    let rumor =
304        chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
305    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
306}
307
308/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
309/// with the replacement text. Author-enforced on the read side — only the
310/// original author's edit folds.
311pub async fn send_edit<T: Transport + ?Sized>(
312    transport: &T,
313    community: &CommunityV2,
314    channel_id: &ChannelId,
315    target_id_hex: &str,
316    new_content: &str,
317) -> Result<String, String> {
318    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
319    let at_ms = now_ms();
320    let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
321    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
322}
323
324/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
325/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
326/// retained per-message stream key (see `publish_chat`).
327pub async fn send_delete<T: Transport + ?Sized>(
328    transport: &T,
329    community: &CommunityV2,
330    channel_id: &ChannelId,
331    target_id_hex: &str,
332    target_kind: u16,
333) -> Result<String, String> {
334    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
335    let at_ms = now_ms();
336    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
337    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
338}
339
340/// Moderation-hide: remove SOMEONE ELSE's message under `MANAGE_MESSAGES`
341/// (CORD-04 §3/§5). Same kind-5 the author's own delete uses — CORD defines no
342/// separate hide, the authority is what differs, and every reader re-derives it
343/// from the seal's real npub against the folded Roster.
344///
345/// Gated locally against the same predicate peers enforce, so the button can't
346/// promise what the plane will refuse; a non-owner cites the Grant it acts under.
347/// `target_author` comes from the caller's resident copy — you can only moderate
348/// a message you can see.
349pub async fn moderation_delete<T: Transport + ?Sized>(
350    transport: &T,
351    community: &CommunityV2,
352    channel_id: &ChannelId,
353    target_id_hex: &str,
354    target_kind: u16,
355    target_author: &PublicKey,
356) -> Result<String, String> {
357    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
358    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
359    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
360        return Err("this community is dissolved — it accepts no new moderation actions".to_string());
361    }
362    let owner_hex = community.owner()?.to_hex();
363    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
364    if !crate::community::moderation::can_hide(
365        Some(&owner_hex),
366        &roster,
367        &author_pk.to_hex(),
368        &target_author.to_hex(),
369    ) {
370        return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
371    }
372    let at_ms = now_ms();
373    let citation = required_authority_citation(community, &author_pk)?;
374    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
375    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
376}
377
378/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
379/// `publish_webxdc_signal`: the same shared content shape, sealed on the
380/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
381/// Signed by the member's real identity — a member can't forge another
382/// player's presence. Failure is non-fatal to callers (the next re-advertise
383/// covers a missed ad).
384pub async fn send_webxdc_signal<T: Transport + ?Sized>(
385    transport: &T,
386    community: &CommunityV2,
387    channel_id: &ChannelId,
388    topic_id: &str,
389    node_addr: Option<&str>,
390) -> Result<(), String> {
391    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
392    let at_ms = now_ms();
393    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
394    let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
395    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
396}
397
398/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
399pub async fn send_typing<T: Transport + ?Sized>(
400    transport: &T,
401    community: &CommunityV2,
402    channel_id: &ChannelId,
403) -> Result<(), String> {
404    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
405    let at_ms = now_ms();
406    let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
407    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
408}
409
410/// Everything a chat-plane send needs: local keys, the channel's group key +
411/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
412/// community (every honest member sealed it read-only) and a keyless Private
413/// channel — deriving from the root would post to the public plane; its key
414/// arrives over the rekey plane.
415fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
416    let session = SessionGuard::capture();
417    let author_pk = me_pk()?;
418    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
419    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
420        return Err("this community has been dissolved".to_string());
421    }
422    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
423    // echo would silently no-op, so fail loudly instead of a message that seems
424    // to send but shows up nowhere.
425    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
426        return Err("you are banned from this community".to_string());
427    }
428    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
429    if ch.private && ch.key.is_none() {
430        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
431    }
432    let (secret, epoch) = community.channel_secret(ch);
433    Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
434}
435
436/// Seal one chat rumor, re-check the session, publish, and echo the send into the
437/// shared store. Returns the rumor id (hex).
438#[allow(clippy::too_many_arguments)]
439async fn publish_chat<T: Transport + ?Sized>(
440    transport: &T,
441    community: &CommunityV2,
442    session: &SessionGuard,
443    group: &GroupKey,
444    author_pk: PublicKey,
445    channel_id: &ChannelId,
446    epoch: Epoch,
447    rumor: nostr_sdk::prelude::UnsignedEvent,
448    at_ms: u64,
449    ephemeral: bool,
450) -> Result<String, String> {
451    let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
452    let signer = crate::signer::active_signer()?;
453    let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
454        .map_err(|e| e.to_string())?;
455    if !session.is_valid() {
456        return Err("account changed before send".to_string());
457    }
458    transport.publish(&wrap, &community.relays).await?;
459    // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
460    // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
461    // everywhere — the discarded p-tag pair only works on recipient-delete relays).
462    // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
463    // straddled network I/O.
464    if !ephemeral {
465        if !session.is_valid() {
466            return Ok(rumor_id);
467        }
468        crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
469    }
470    // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
471    // send-then-read works with no listen loop, and the relay's re-delivery dedups
472    // against this row instead of re-firing callbacks. Best-effort — the publish
473    // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
474    if !ephemeral {
475        if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
476            let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
477            let outcome = {
478                let mut st = crate::state::STATE.lock().await;
479                if !session.is_valid() {
480                    return Ok(rumor_id); // swapped on the lock await — never echo into another account.
481                }
482                super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
483            };
484            if let Some(outcome) = outcome {
485                if !session.is_valid() {
486                    return Ok(rumor_id);
487                }
488                super::inbound::persist_chat(&channel_hex, &outcome).await;
489            }
490        }
491    }
492    Ok(rumor_id)
493}
494
495/// A chat event opened from a channel fetch, tagged with the epoch its key
496/// decrypted under.
497pub struct FetchedEvent {
498    pub event: ChatEvent,
499    pub epoch: Epoch,
500}
501
502/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
503/// pre-retention and other-device sends stay fully deletable, because the wrap's
504/// signing key is the derivable group stream key — only this rumor→wrap mapping
505/// was ever missing locally. No-op for foreign authors, kinds the UI can't
506/// delete, and already-retained rows. Best-effort: a store failure never breaks
507/// the fetch.
508fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
509    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
510        return;
511    }
512    let opened = event.opened();
513    if crate::state::my_public_key() != Some(opened.author) {
514        return;
515    }
516    let rumor_hex = opened.rumor_id.to_hex();
517    // Only fill a confirmed gap — never clobber a send-time row, never write
518    // when the store can't be read.
519    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
520        return;
521    }
522    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
523        // The UI caches full-vs-limited delete verdicts per message; tell it this
524        // one just flipped so it re-resolves without an app restart.
525        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
526    }
527}
528
529/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
530/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
531/// per epoch; deeper history pages backwards via the walk.
532pub async fn fetch_channel<T: Transport + ?Sized>(
533    transport: &T,
534    community: &CommunityV2,
535    channel_id: &ChannelId,
536    limit: usize,
537) -> Result<Vec<FetchedEvent>, String> {
538    fetch_channel_history(transport, community, channel_id, limit, 1, None, crate::community::transport::Evidence::Quorum, |_| true).await
539}
540
541/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
542/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
543/// address one `page`-sized query at a time until `max_pages`, a drained relay,
544/// or `keep_paging` returns false for a page (the caller's "I already hold
545/// these" early stop — consulted only on pages that opened something, so junk
546/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
547/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
548/// full page of only-already-seen wraps is a same-second WALL (relay filters
549/// are second-granular) and steps past it accepting that unseen same-second
550/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
551/// limitation (the `ms` tag can't be filtered server-side).
552///
553/// Returns everything opened, deduped by rumor id, oldest→newest.
554pub async fn fetch_channel_history<T: Transport + ?Sized>(
555    transport: &T,
556    community: &CommunityV2,
557    channel_id: &ChannelId,
558    page: usize,
559    max_pages: usize,
560    since: Option<u64>,
561    evidence: crate::community::transport::Evidence,
562    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
563) -> Result<Vec<FetchedEvent>, String> {
564    // Guards the opportunistic scrub-key heals below — the fetch loop straddles
565    // network I/O, and an account swap must not write into the new account's DB.
566    let session = SessionGuard::capture();
567    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
568    // A Public channel reads across EVERY held base-root epoch, and a Private one
569    // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
570    // continuous either way. A keyless Private channel is unreadable — never derived
571    // from the root (that would address the public plane).
572    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
573    let coords: Vec<([u8; 32], Epoch)> = if ch.private {
574        let Some(current) = ch.key else {
575            return Ok(Vec::new());
576        };
577        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
578        let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
579        if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
580            held.push((ch.epoch, current));
581        }
582        // Only real grants are archived, but keep the invariant local: a private
583        // plane is never read with the root value.
584        held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
585    } else {
586        let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
587        if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
588            roots.push((community.root_epoch, community.community_root));
589        }
590        roots.into_iter().map(|(ep, root)| (root, ep)).collect()
591    };
592    if coords.is_empty() {
593        return Ok(Vec::new());
594    }
595
596    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
597    let mut seen_rumors = std::collections::HashSet::new();
598    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
599    let mut until: Option<u64> = None;
600    let mut oldest: Option<u64> = None;
601    for _ in 0..max_pages {
602        // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
603        // relays (Ditto) require the connection authed as the author queried and
604        // reject a multi-author REQ ("all authors must be authenticated"), so a
605        // single merged fetch returns nothing there — the latest messages under a
606        // freshly-adopted epoch never load. Per-plane authed fetches + union.
607        let mut wraps: Vec<Event> = Vec::new();
608        let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
609        for (secret, epoch) in &coords {
610            let plane = channel_group_key(secret, channel_id, *epoch);
611            let q = Query {
612                kinds: vec![stream::KIND_WRAP],
613                authors: vec![plane.pk_hex()],
614                since,
615                until,
616                limit: Some(page),
617                evidence,
618                ..Default::default()
619            };
620            if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
621                for e in evs {
622                    if wrap_ids.insert(e.id) {
623                        wraps.push(e);
624                    }
625                }
626            }
627        }
628        if wraps.is_empty() {
629            break;
630        }
631        let mut fresh = 0usize;
632        let mut page_events: Vec<FetchedEvent> = Vec::new();
633        for wrap in &wraps {
634            if !seen_wraps.insert(wrap.id) {
635                continue;
636            }
637            fresh += 1;
638            let at = wrap.created_at.as_secs();
639            if oldest.is_none_or(|o| at < o) {
640                oldest = Some(at);
641            }
642            // Select the epoch whose group key authored this wrap (no trial decrypt).
643            for (secret, epoch) in &coords {
644                let group = channel_group_key(secret, channel_id, *epoch);
645                if wrap.pubkey != group.pk() {
646                    continue;
647                }
648                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
649                    let id = event.opened().rumor_id;
650                    if seen_rumors.insert(id) {
651                        if session.is_valid() {
652                            heal_own_wrap_key(&event, &group, &community.relays);
653                        }
654                        page_events.push(FetchedEvent { event, epoch: *epoch });
655                    }
656                }
657                break;
658            }
659        }
660        if fresh == 0 {
661            if wraps.len() < page {
662                break; // drained — the relay has nothing older.
663            }
664            // A full page of already-seen wraps: a same-second WALL. Step past it;
665            // same-second siblings beyond the relay's cap are unreachable by a
666            // second-granular filter.
667            let Some(o) = oldest else { break };
668            if o == 0 {
669                break;
670            }
671            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
672            until = Some(o - 1);
673            continue;
674        }
675        let stop = !page_events.is_empty() && !keep_paging(&page_events);
676        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
677        if stop {
678            break; // the caller holds everything from here back.
679        }
680        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
681    }
682    out.sort_by_key(|(ms, _)| *ms);
683    Ok(out.into_iter().map(|(_, e)| e).collect())
684}
685
686// ── Invites (CORD-05) ────────────────────────────────────────────────────────
687
688/// Build the §1 invite bundle for this community. Every channel is granted: a
689/// Public channel carries the `community_root` as its "key" (the joiner derives
690/// the real secret from the root), a Private one its own key. The bundle
691/// self-certifies the owner, so the inviter's identity is irrelevant to trust.
692pub fn bundle_of(
693    community: &CommunityV2,
694    creator: Option<PublicKey>,
695    expires_at_ms: Option<u64>,
696    label: Option<String>,
697) -> CommunityInvite {
698    let hex = crate::simd::hex::bytes_to_hex_32;
699    let channels = community
700        .channels
701        .iter()
702        // A KEYLESS private channel can't be granted (we hold no key) — carrying the
703        // root placeholder would make the joiner classify it PUBLIC and address a
704        // private channel at the public plane. It joins their view via control-follow
705        // (keyless) and keys up at the channel's next rotation.
706        .filter(|c| !(c.private && c.key.is_none()))
707        .map(|c| invite::ChannelGrant {
708            id: hex(&c.id.0),
709            key: hex(&c.key.unwrap_or(community.community_root)),
710            epoch: c.epoch.0,
711            name: c.name.clone(),
712        })
713        .collect();
714    CommunityInvite {
715        community_id: hex(&community.identity.community_id.0),
716        owner: hex(&community.identity.owner_xonly),
717        owner_salt: hex(&community.identity.owner_salt),
718        community_root: hex(&community.community_root),
719        root_epoch: community.root_epoch.0,
720        channels,
721        relays: community.relays.clone(),
722        name: community.name.clone(),
723        // Mint-time snapshot so a parked invite renders the real logo before any
724        // fold; the Control Plane stays the authority after joining.
725        icon: community.icon.clone(),
726        expires_at: expires_at_ms,
727        creator_npub: creator.map(|p| p.to_hex()),
728        label,
729        extra: Default::default(),
730    }
731}
732
733/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
734/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
735/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
736/// bundle hands over the keys; the recipient consents by accepting (nothing joins
737/// on receipt). Returns the wrap.
738pub async fn send_direct_invite<T: Transport + ?Sized>(
739    transport: &T,
740    community: &CommunityV2,
741    recipient: &PublicKey,
742    expires_at_ms: Option<u64>,
743    label: Option<String>,
744) -> Result<Event, String> {
745    let session = SessionGuard::capture();
746    // A stale bundle is worse than a stale edit: it hands the joiner keys to a
747    // buried epoch, and their client later self-evicts on the rekey exclusion.
748    assert_current_root(community)?;
749    let signer = crate::signer::active_signer()?;
750    let inviter_pk = me_pk()?;
751    let bundle = bundle_of(community, Some(inviter_pk), expires_at_ms, label);
752    let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
753    if !session.is_valid() {
754        return Err("account changed before sending invite".to_string());
755    }
756    transport.publish(&wrap, &community.relays).await?;
757    Ok(wrap)
758}
759
760/// A minted public link: the shareable URL plus the addressable bundle event to
761/// publish and the link keypair to retain (in the Invite List) for later refresh
762/// or revocation.
763pub struct MintedLink {
764    pub url: String,
765    pub bundle_event: Event,
766    pub link_signer: Keys,
767    pub token: [u8; super::derive::TOKEN_LEN],
768    /// Unix ms, mirrored from the bundle. The Invite List is the creator's only
769    /// record of it, and the Registry prunes on it — the coordinate a member
770    /// folds carries no expiry, so a lapsed link the creator never pruned reads
771    /// as a live door forever (CORD-05 §4/§5).
772    pub expires_at_ms: Option<u64>,
773    pub label: Option<String>,
774}
775
776/// Mint a public invite link for this community: a fresh token + link keypair, the
777/// bundle encrypted under the token key and published at `(33301, link_signer,
778/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
779/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
780/// relays and never reaches a server.
781pub async fn mint_public_link<T: Transport + ?Sized>(
782    transport: &T,
783    community: &CommunityV2,
784    base: &str,
785    expires_at_ms: Option<u64>,
786    label: Option<String>,
787) -> Result<MintedLink, String> {
788    let session = SessionGuard::capture();
789    let mut token = [0u8; super::derive::TOKEN_LEN];
790    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
791    let link_signer = Keys::generate();
792    let bundle = bundle_of(community, Some(me_pk()?), expires_at_ms, label.clone());
793    let bundle_key = super::derive::invite_bundle_key(&token);
794    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
795    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
796
797    if !session.is_valid() {
798        return Err("account changed before minting link".to_string());
799    }
800    transport.publish_durable(&bundle_event, &community.relays).await?;
801    let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
802    // Sync the link across the creator's devices (13303) + publish the Registry
803    // (vsk-8) so members see the community is Public. Best-effort — the link works
804    // without the sync.
805    let _ = record_minted_link(transport, community, &minted).await;
806    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
807    // the 13303 list remains the cross-device record. Re-check the session: the
808    // publishes above straddled awaits, and this write must not land account A's
809    // link (secret token included) in a swapped-in account's DB.
810    if session.is_valid() {
811        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
812        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
813        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
814    }
815    Ok(minted)
816}
817
818// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
819
820/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
821/// decrypt/parse failure is "no news", never a clobber of the local mirror).
822/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
823/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
824/// fresh one wipes every link minted on other devices. Full evidence for the
825/// same reason — this read feeds replaceable-event writes.
826async fn fetch_invite_list<T: Transport + ?Sized>(
827    transport: &T,
828    relays: &[String],
829) -> Result<Option<invite::InviteList>, String> {
830    let signer = crate::signer::active_signer()?;
831    let my_pk = me_pk()?;
832    let query = Query {
833        kinds: vec![super::kind::INVITE_LIST],
834        authors: vec![my_pk.to_hex()],
835        limit: Some(4),
836        evidence: crate::community::transport::Evidence::Full,
837        ..Default::default()
838    };
839    let events = transport.fetch(&query, relays).await?;
840    let mut best: Option<(u64, invite::InviteList)> = None;
841    for e in events {
842        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
843            let at = e.created_at.as_secs();
844            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
845                best = Some((at, l));
846            }
847        }
848    }
849    Ok(best.map(|(_, l)| l))
850}
851
852/// The creator's LIVE link-signer pubkeys for one community — the Registry's
853/// content (CORD-05 §5), derived from the stored link secrets.
854///
855/// Live means neither tombstoned nor EXPIRED. An expired link cannot be joined
856/// (`InviteBundle::expired`, CORD-05 §1), so leaving it in the Registry states
857/// a door that isn't there: the aggregate never empties, the community reads
858/// Public forever, and every gate hanging off that reading silently inverts.
859fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
860    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
861    list.entries
862        .iter()
863        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
864        .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
865        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
866        .collect()
867}
868
869/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
870/// community — so members fold it into the Public/Private source of truth (a
871/// non-empty aggregate = Public).
872async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
873    let my_pk = me_pk()?;
874    let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
875    let content = invite::build_registry_content(live_signers);
876    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await
877}
878
879/// Record a freshly-minted public link across the creator's devices: append it to the
880/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
881async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
882    let session = SessionGuard::capture();
883    let signer = crate::signer::active_signer()?;
884    let my_pk = me_pk()?;
885    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
886    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
887    // Err aborts the sync half (the link's bundle already published durably;
888    // a retry re-records it) — an unreachable relay set must never be mistaken
889    // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
890    // creator's honest first list.
891    let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
892    if !list.entries.iter().any(|e| e.token == token_hex) {
893        list.entries.push(invite::InviteEntry {
894            token: token_hex,
895            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
896            community_id: cid_hex.clone(),
897            url: minted.url.clone(),
898            label: minted.label.clone(),
899            created_at: now_ms() / 1000,
900            expires_at: minted.expires_at_ms,
901            extra: Default::default(),
902        });
903    }
904    if !session.is_valid() {
905        return Err("account changed during link record".to_string());
906    }
907    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
908    transport.publish(&event, &community.relays).await?;
909    let signers = live_signers_for(&list, &cid_hex, now_ms());
910    publish_invite_registry(transport, community, &session, &signers).await
911}
912
913/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
914/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
915/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
916/// LAST live link empties the Registry → the community reads Private (a Refounding is
917/// the owner's separate read-cut).
918pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
919    let session = SessionGuard::capture();
920    let signer = crate::signer::active_signer()?;
921    let my_pk = me_pk()?;
922    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
923    let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
924    let entry = list
925        .entries
926        .iter()
927        .find(|e| e.token == token_hex && e.community_id == cid_hex)
928        .cloned()
929        .ok_or("no such link in the invite list")?;
930    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
931    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
932    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
933    if !session.is_valid() {
934        return Err("account changed during revoke".to_string());
935    }
936    transport.publish_durable(&revocation, &community.relays).await?;
937    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
938    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
939    list.entries.retain(|e| e.token != token_hex);
940    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
941    transport.publish(&event, &community.relays).await?;
942    let signers = live_signers_for(&list, &cid_hex, now_ms());
943    publish_invite_registry(transport, community, &session, &signers).await?;
944    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
945    if session.is_valid() {
946        let _ = crate::db::community::delete_public_invite(token_hex);
947    }
948    Ok(())
949}
950
951/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
952/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
953/// coordinate with the CURRENT community state, so a link shared once keeps working
954/// across rotations. Best-effort.
955pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
956    let session = SessionGuard::capture();
957    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
958    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
959    // Err — the caller (a post-refounding refresh) must be able to retry, or live
960    // links keep serving the PRE-refound root and new joiners land on the dead
961    // epoch. A genuinely-empty list is Ok (nothing to refresh).
962    let signer = crate::signer::active_signer()?;
963    let my_pk = me_pk()?;
964    let query = Query {
965        kinds: vec![super::kind::INVITE_LIST],
966        authors: vec![my_pk.to_hex()],
967        limit: Some(4),
968        ..Default::default()
969    };
970    let events = transport.fetch(&query, &community.relays).await?;
971    let mut best: Option<(u64, invite::InviteList)> = None;
972    for e in events {
973        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
974            let at = e.created_at.as_secs();
975            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
976                best = Some((at, l));
977            }
978        }
979    }
980    let Some((_, list)) = best else {
981        return Ok(());
982    };
983    let creator = my_pk;
984    let now = now_ms();
985    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
986    for entry in &list.entries {
987        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
988            continue;
989        }
990        // An expired link can't be joined, so refreshing it just re-states a
991        // door that isn't there (CORD-05 §1/§5).
992        if entry.expires_at.is_some_and(|exp| now > exp) {
993            continue;
994        }
995        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
996        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
997        let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
998        let bundle_key = super::derive::invite_bundle_key(&token);
999        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1000            if !session.is_valid() {
1001                return Err("account changed during link refresh".to_string());
1002            }
1003            let _ = transport.publish_durable(&event, &community.relays).await;
1004        }
1005    }
1006    // Republish the Registry from the same pruned view. Expiry is the one way a
1007    // link dies with no user action, so without a heal point here the aggregate
1008    // never empties and the community reads Public long after its last door
1009    // shut (CORD-05 §5). Idempotent when nothing lapsed.
1010    //
1011    // Only for a creator who actually minted here: one Invite List spans every
1012    // community, so a member holding links ELSEWHERE would otherwise publish an
1013    // empty Registry edition into this one on every rotation they adopt — a
1014    // control-plane write, and a version bump, for a coordinate they never owned.
1015    let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1016    if !mine_here {
1017        return Ok(());
1018    }
1019    let signers = live_signers_for(&list, &cid_hex, now);
1020    if !session.is_valid() {
1021        return Err("account changed during link refresh".to_string());
1022    }
1023    let _ = publish_invite_registry(transport, community, &session, &signers).await;
1024    Ok(())
1025}
1026
1027/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1028/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1029/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1030/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1031pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1032    use crate::community::roles::Permissions;
1033    use std::collections::BTreeMap;
1034    let Ok(owner) = community.owner() else { return false };
1035    let owner_hex = owner.to_hex();
1036    let cid = community.id();
1037    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1038    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1039        .unwrap_or_default()
1040        .into_iter()
1041        .filter(|(_, f)| f.0 == community.root_epoch.0)
1042        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1043        .collect();
1044    let control = control_group_key(&community.community_root, cid, community.root_epoch);
1045    // WHOLE plane, not the newest page. Public-vs-Private is decided by whether any
1046    // live invite link exists, so a registry pushed out of a single window reads as
1047    // retired — and any member can push it out, since the plane key comes from the
1048    // community root they hold. Truncation fails toward Public: over-stating it only
1049    // makes a caller take the stronger remedy (privatise + re-found + reissue), while
1050    // under-stating it leaves a live link open behind a ban.
1051    let mut editions: Vec<ParsedEdition> = Vec::new();
1052    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1053    let mut oldest: Option<u64> = None;
1054    let mut until: Option<u64> = None;
1055    for page in 0..COMPACT_MAX_PAGES {
1056        // Quorum, DECLARED (the until→Full transport floor is gone): these
1057        // control reads tolerate a partial union — their fold semantics are
1058        // fail-safe on gaps (seeded banlists, withheld roster cache).
1059        let query = Query {
1060            kinds: vec![stream::KIND_WRAP],
1061            authors: vec![control.pk_hex()],
1062            until,
1063            limit: Some(FOLLOW_PAGE),
1064            evidence: crate::community::transport::Evidence::Quorum,
1065            ..Default::default()
1066        };
1067        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return true };
1068        let mut fresh = 0usize;
1069        for w in &wraps {
1070            if !seen_wraps.insert(w.id) {
1071                continue;
1072            }
1073            fresh += 1;
1074            let at = w.created_at.as_secs();
1075            if oldest.is_none_or(|o| at < o) {
1076                oldest = Some(at);
1077            }
1078            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1079                editions.push(ed);
1080            }
1081        }
1082        if fresh == 0 {
1083            if wraps.len() >= FOLLOW_PAGE {
1084                return true; // same-second wall: the plane can't be read whole
1085            }
1086            break;
1087        }
1088        until = oldest;
1089        if page + 1 == COMPACT_MAX_PAGES {
1090            return true;
1091        }
1092    }
1093    let authority = fold_authority(community, &editions, &floors);
1094
1095    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1096    for e in &editions {
1097        if e.vsk == vsk::INVITE_LINKS {
1098            by_eid.entry(e.entity_id).or_default().push(e);
1099        }
1100    }
1101    for (eid, group) in &by_eid {
1102        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1103        // picks an equal-version winner author-blind (lowest inner id, which an
1104        // author can grind), so folding first would let any member occupy the head
1105        // slot and have the whole registry dropped by the check below — silently
1106        // retiring a live invite link, i.e. flipping the community to Private.
1107        let authed: Vec<&ParsedEdition> = group
1108            .iter()
1109            .copied()
1110            .filter(|p| {
1111                let author = p.author.to_hex();
1112                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1113                !authority.banned.contains(&author)
1114                    && authority.roles.is_authorized(&author, Some(&owner_hex), Permissions::CREATE_INVITE)
1115                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1116            })
1117            .collect();
1118        if authed.is_empty() {
1119            continue;
1120        }
1121        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1122        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1123        if invite::parse_registry_content(&authed[hi].content).map(|s| !s.is_empty()).unwrap_or(false) {
1124            return true;
1125        }
1126    }
1127    false
1128}
1129
1130/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1131/// delivered community_root is genuinely the owner's, persist the community, and
1132/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1133/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
1134/// caller did) so the `is_valid()` gate straddles that I/O.
1135async fn accept_bundle<T: Transport + ?Sized>(
1136    transport: &T,
1137    session: &SessionGuard,
1138    bundle: &CommunityInvite,
1139    invited_by: Option<PublicKey>,
1140    announce_join: bool,
1141) -> Result<CommunityV2, String> {
1142    let signer = crate::signer::active_signer()?;
1143    let my_pk = me_pk()?;
1144    let at_ms = now_ms();
1145    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1146    if bundle.expired(at_ms) {
1147        return Err("this invite has expired".to_string());
1148    }
1149    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1150    let community = CommunityV2::from_bundle(bundle, at_ms)?;
1151    // Captured before the save below: a re-accept of a held community must not
1152    // re-announce a membership this account already declared.
1153    let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1154
1155    // Authenticate the delivered community_root before trusting it. The owner
1156    // commitment proves WHO the owner is, but community_root (and channel keys) are
1157    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1158    // with an attacker-chosen root and silently partition the joiner onto planes
1159    // only the attacker controls. Requiring the owner's genesis to open under the
1160    // delivered root closes that eclipse; also reconciles channel classification.
1161    // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1162    // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1163    let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1164        v.session.is_valid()
1165            && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1166            && v.community_id == community.id().0
1167            && v.community_root == community.community_root
1168    });
1169    let (community, join_heads, join_banlist) = match handoff {
1170        Some(v) => {
1171            let mut c = v.folded;
1172            // The preview holds no acquisition time — stamp the JOIN's.
1173            c.created_at_ms = at_ms;
1174            (c, v.heads, v.banned)
1175        }
1176        None => verify_owner_root_and_reconcile(transport, community).await?,
1177    };
1178
1179    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1180    if is_dissolved(transport, &community).await {
1181        return Err("this community has been dissolved".to_string());
1182    }
1183
1184    // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1185    // community whose authorized banlist names it — before the Guestbook Join publishes
1186    // and before any local write. Every door funnels through here (direct invite, parked,
1187    // public link, migration), so none of them needs its own exclusion.
1188    if join_banlist.contains(&my_pk.to_hex()) {
1189        return Err("you are banned from this community".to_string());
1190    }
1191
1192    // The account must not have swapped since the guard was captured (which was
1193    // before any fetch the caller / the verify above performed) — else we'd write
1194    // A's join into B.
1195    if !session.is_valid() {
1196        return Err("account changed during join".to_string());
1197    }
1198    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1199    // community row lands (floors-then-state, so a mid-seed error can't leave saved
1200    // state outrunning its floor); the first post-join follow then can't persist a
1201    // state below what this join already showed.
1202    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1203    for h in &join_heads {
1204        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)?;
1205    }
1206    crate::db::community::save_community_v2(&community)?;
1207    // Archive the joined root at its epoch, so this member reads Public-channel
1208    // history from their join epoch onward across later Refoundings (CORD-03 §3).
1209    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1210    // Same for each granted Private-channel key: the archive is what lets its
1211    // history stay readable after the channel rotates away from this key.
1212    for ch in &community.channels {
1213        if let (true, Some(key)) = (ch.private, ch.key) {
1214            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1215        }
1216    }
1217
1218    // Announce our Guestbook Join, echoing the invite attribution when present.
1219    // Only an ACTUAL join speaks: a re-accept of a held community, or a
1220    // cross-device key sync (announce_join=false), is not a membership event —
1221    // the account's original Join already stands in the guestbook, and every
1222    // re-publish renders as "<user> has joined" spam for the whole community.
1223    if announce_join && !already_held {
1224        let attribution = invited_by
1225            .map(|p| p.to_hex())
1226            .or_else(|| bundle.creator_npub.clone())
1227            .zip(Some(bundle.label.clone().unwrap_or_default()));
1228        let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1229        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1230        let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1231        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1232            let _ = transport.publish(&join_wrap, &community.relays).await;
1233        }
1234    }
1235
1236    // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1237    // happy path; anything else hands off to the durable retry, because an unrecorded
1238    // join is what strands a community behind a stale tombstone.
1239    match republish_community_list(transport, Some(community.id())).await {
1240        Ok(true) => {}
1241        Ok(false) => republish_community_list_durable(Some(*community.id())),
1242        Err(e) => {
1243            crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1244            republish_community_list_durable(Some(*community.id()));
1245        }
1246    }
1247    Ok(community)
1248}
1249
1250/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1251/// channel classification from the owner's editions. `community_id` commits only
1252/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1253/// every synced Community List) — so a forged invite can present a real community's
1254/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1255/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1256/// owner commitment still "verifies". The defense: the owner's genesis metadata
1257/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1258/// attacker can't forge the owner's seal — so its presence on the control plane
1259/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1260/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1261/// re-wraps heads with their original signatures), so the anchor there is the
1262/// community-bound metadata head plus any owner-signed edition under the same root.
1263/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1264/// On success, folds the owner's authoritative editions to heal a bundle that
1265/// misclassified a channel.
1266async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1267    transport: &T,
1268    community: CommunityV2,
1269) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1270    let owner = community.owner()?;
1271    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1272    let control_pk = control.pk_hex();
1273
1274    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1275    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1276    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1277    // satisfy the gate and the control plane reads back empty. Register this
1278    // community's stream keys + start the challenge responder so the fetch below
1279    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1280    super::streamauth::prime(&community);
1281
1282    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1283    // community_id`) at the root-derived control plane. The genesis eid pins it to
1284    // THIS community, and it lives ONLY under the real root — so a forged root can't
1285    // produce one: an edition's seal carries no community binding, but another
1286    // community's genesis has a different eid, and this community's own genesis is
1287    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1288    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1289    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1290    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1291    // binding the root into community_id (protocol, deferred).
1292    //
1293    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1294    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1295    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1296    // an open partial window misses a genesis on a lagging relay (routine over
1297    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1298    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1299    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1300    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1301    // that buries the genesis past the walk is the deferred protocol residual.
1302    const PAGE: usize = 500;
1303    const MAX_PAGES: usize = 4;
1304    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1305    let mut editions: Vec<ParsedEdition> = Vec::new();
1306    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1307    let mut found_genesis = false;
1308    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1309    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1310    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1311    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1312    // at least one owner-signed edition opened under this root. A non-member forger
1313    // can produce neither; the sibling-community rewrap residual this reopens is the
1314    // same class the spec defers to root-in-id binding.
1315    let mut compacted_metadata = false;
1316    crate::log_debug!(
1317        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1318        &control_pk[..12], community.root_epoch, community.relays
1319    );
1320    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1321        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1322    };
1323    for attempt in 0..2 {
1324        editions.clear();
1325        all_editions.clear();
1326        compacted_metadata = false;
1327        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1328        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1329        for page_no in 0..MAX_PAGES {
1330            let query = Query {
1331                kinds: vec![stream::KIND_WRAP],
1332                authors: vec![control_pk.clone()],
1333                until,
1334                limit: Some(PAGE),
1335                evidence: crate::community::transport::Evidence::Full,
1336                ..Default::default()
1337            };
1338            let wraps = transport.fetch(&query, &community.relays).await?;
1339            crate::log_trace!(
1340                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1341                attempt, page_no, wraps.len()
1342            );
1343            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1344            // siblings at a page boundary (and the genesis with them); re-served
1345            // boundary events are free, and no-new-events means exhausted.
1346            let mut oldest = u64::MAX;
1347            let mut fresh = 0usize;
1348            for w in &wraps {
1349                if !seen_wraps.insert(w.id) {
1350                    continue;
1351                }
1352                fresh += 1;
1353                oldest = oldest.min(w.created_at.as_secs());
1354                if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1355                    crate::log_trace!(
1356                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1357                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1358                        ed.author == owner, w.created_at.as_secs()
1359                    );
1360                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1361                        if ed.author == owner {
1362                            found_genesis = true;
1363                        } else {
1364                            compacted_metadata = true;
1365                        }
1366                    }
1367                    if ed.author == owner {
1368                        editions.push(ed.clone());
1369                    }
1370                    // Any-author set for the join-time authority fold below: the banlist head
1371                    // may be admin-signed, and its authority chains to the owner regardless.
1372                    all_editions.push(ed);
1373                }
1374            }
1375            crate::log_debug!(
1376                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1377                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1378            );
1379            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1380                break; // authenticated, or the relay is exhausted.
1381            }
1382            until = Some(oldest);
1383        }
1384        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1385            break;
1386        }
1387        if attempt == 0 {
1388            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1389            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1390            // satisfy a stream-authors gate — and can land before the responder's
1391            // stream-key auth settles, reading the plane back EMPTY. Replay the
1392            // remembered challenges for every registered stream key, then walk once
1393            // more on the settled connection.
1394            if let Some(client) = crate::state::nostr_client() {
1395                super::streamauth::prime_auth(&client, &community.relays).await;
1396            }
1397        }
1398    }
1399    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1400        return Err(
1401            "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"
1402                .to_string(),
1403        );
1404    }
1405    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1406    // entity). The heads this fold verified are returned for the caller to SEED as
1407    // the initial floor once the community row is saved — without that, the first
1408    // post-join follow would bootstrap floor-less and could persist a state BELOW
1409    // what this join already verified and showed.
1410    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1411    // above), and the owner is supreme — so owner-only authority suffices. The full
1412    // roster (admins) folds on the first post-join follow_control.
1413    let empty_floors = Floors::new();
1414    let authority = AuthoritySet::owner_only();
1415    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1416    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1417    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1418    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1419    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1420    let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1421    Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1422}
1423
1424/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1425/// then run the shared accept path. The recipient's consent IS this call. No
1426/// network await precedes the accept, so the guard captured here suffices.
1427pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1428    let session = SessionGuard::capture();
1429    let signer = crate::signer::active_signer()?;
1430    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1431    accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1432}
1433
1434/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1435/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1436/// bundle validation, then runs the shared accept path (which re-verifies the owner
1437/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1438/// Join attribution.
1439pub async fn accept_parked_invite<T: Transport + ?Sized>(
1440    transport: &T,
1441    bundle_json: &str,
1442    inviter_hex: Option<&str>,
1443) -> Result<CommunityV2, String> {
1444    let session = SessionGuard::capture();
1445    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1446    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1447    accept_bundle(transport, &session, &bundle, invited_by, true).await
1448}
1449
1450/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1451/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1452/// path, which re-verifies the owner root over the network and enforces the join-time ban
1453/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1454/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1455pub async fn accept_migration_material<T: Transport + ?Sized>(
1456    transport: &T,
1457    jm: &super::list::JoinMaterial,
1458) -> Result<CommunityV2, String> {
1459    let session = SessionGuard::capture();
1460    let bundle = material_to_invite(jm);
1461    accept_bundle(transport, &session, &bundle, None, true).await
1462}
1463
1464/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1465/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1466/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1467/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1468/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1469/// the newest valid Live by `created_at`. Read-only.
1470pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1471    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1472    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1473    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1474    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1475    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1476    let query = Query {
1477        kinds: vec![super::kind::INVITE_BUNDLE],
1478        authors: vec![parsed.link_signer.to_hex()],
1479        ..Default::default()
1480    };
1481    let relays = if parsed.bootstrap_relays.is_empty() {
1482        invite::stock_relays()
1483    } else {
1484        parsed.bootstrap_relays.clone()
1485    };
1486    // One bounded retry: a join fired while the pool is still warming (bootstrap
1487    // relays mid-handshake, routine during boot contention) reads back a transport
1488    // error, not an absent bundle. The pool add already happened on the first try,
1489    // so wait for a socket rather than guessing with a fixed sleep.
1490    let events = match transport.fetch(&query, &relays).await {
1491        Ok(evs) => evs,
1492        Err(_) => {
1493            wait_for_bootstrap_relay(&relays).await;
1494            transport.fetch(&query, &relays).await?
1495        }
1496    };
1497    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1498
1499    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1500    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1501    for event in &events {
1502        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1503            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1504            Ok(invite::BundleState::Live(bundle)) => {
1505                let at = event.created_at.as_secs();
1506                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1507                    newest_live = Some((at, *bundle));
1508                }
1509            }
1510            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1511        }
1512    }
1513    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1514}
1515
1516/// Wait — bounded — for ANY of the targets to report Connected before a retry:
1517/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
1518/// handshake takes under boot contention.
1519async fn wait_for_bootstrap_relay(relays: &[String]) {
1520    let Some(client) = crate::state::nostr_client() else { return };
1521    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1522    loop {
1523        for url in relays {
1524            if let Ok(Some(relay)) = client.relay(url).await {
1525                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1526                    return;
1527                }
1528            }
1529        }
1530        if tokio::time::Instant::now() >= deadline {
1531            return;
1532        }
1533        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1534    }
1535}
1536
1537/// The most recent owner-root verification a PREVIEW completed, handed to a join
1538/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1539/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1540/// a different delivered root never matches. The join's own bundle re-fetch is
1541/// untouched, so the revocation gate always runs live.
1542struct VerifiedPreview {
1543    session: SessionGuard,
1544    at: std::time::Instant,
1545    community_id: [u8; 32],
1546    community_root: [u8; 32],
1547    folded: CommunityV2,
1548    heads: Vec<FoldedHead>,
1549    /// The join-time authorized banlist from the SAME verified walk — carried so the
1550    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
1551    banned: std::collections::BTreeSet<String>,
1552}
1553static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1554const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1555
1556/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1557/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1558/// carries no icon — the fold is the authority). Owner-root verification rides
1559/// the fold, so a forged-root link can't render a convincing preview; on a
1560/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1561/// — the caller hasn't joined.
1562pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1563    let bundle = fetch_public_bundle(transport, url).await?;
1564    preview_bundle(transport, &bundle).await
1565}
1566
1567/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1568/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1569/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1570/// it again regardless.
1571pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1572    let community = CommunityV2::from_bundle(bundle, 0)?;
1573    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1574        Ok((folded, heads, banned)) => {
1575            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1576                session: SessionGuard::capture(),
1577                at: std::time::Instant::now(),
1578                community_id: folded.id().0,
1579                community_root: folded.community_root,
1580                folded: folded.clone(),
1581                heads,
1582                banned,
1583            });
1584            Ok(folded)
1585        }
1586        Err(_) => Ok(community),
1587    }
1588}
1589
1590/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1591pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1592    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
1593    let session = SessionGuard::capture();
1594    let bundle = fetch_public_bundle(transport, url).await?;
1595    if !session.is_valid() {
1596        return Err("account changed during join".to_string());
1597    }
1598    accept_bundle(transport, &session, &bundle, None, true).await
1599}
1600
1601/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1602pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1603    let session = SessionGuard::capture();
1604    let signer = crate::signer::active_signer()?;
1605    let my_pk = me_pk()?;
1606    let at_ms = now_ms();
1607    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1608    let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1609    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1610        let _ = transport.publish(&wrap, &community.relays).await;
1611    }
1612    if !session.is_valid() {
1613        return Err("account changed during leave".to_string());
1614    }
1615    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1616    // to the leaving community's own relays (it's about to be gone locally) —
1617    // best-effort.
1618    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1619    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1620    if !session.is_valid() {
1621        return Err("account changed during leave".to_string());
1622    }
1623    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1624    Ok(())
1625}
1626
1627/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1628/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1629/// `can_kick`), so publishing without authority is inert. A kicked member may
1630/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1631pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1632    let session = SessionGuard::capture();
1633    assert_current_root(community)?;
1634    let signer = crate::signer::active_signer()?;
1635    let my_pk = me_pk()?;
1636    // Fast local pre-check; readers re-verify independently.
1637    let authority = fetch_authority(transport, community).await;
1638    let owner_hex = community.owner()?.to_hex();
1639    if !authority.roles.can_act_on_member(
1640        &my_pk.to_hex(),
1641        Some(&owner_hex),
1642        &target.to_hex(),
1643        crate::community::roles::Permissions::KICK,
1644    ) {
1645        return Err("not authorized to kick this member".to_string());
1646    }
1647    // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
1648    // first, so the target's rank is gone before the departure lands. Without it a
1649    // kicked admin leaves the memberlist still holding every management bit, and
1650    // every client keeps honoring their control editions.
1651    //
1652    // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
1653    // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
1654    // target just keeps their rank until an authorized strip lands. Each layer
1655    // validates on its own rule, so a missing one is a weaker removal, never a
1656    // broken one. A strip we DO attempt and lose is a hard error: proceeding would
1657    // publish a directive we know leaves rank behind.
1658    let target_hex = target.to_hex();
1659    let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1660    let may_strip = authority.roles.can_act_on_member(
1661        &my_pk.to_hex(),
1662        Some(&owner_hex),
1663        &target_hex,
1664        crate::community::roles::Permissions::MANAGE_ROLES,
1665    );
1666    if holds_roles && may_strip {
1667        grant_roles(transport, community, target, Vec::new())
1668            .await
1669            .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1670        if !session.is_valid() {
1671            return Err("account changed during kick".to_string());
1672        }
1673    }
1674    let at_ms = now_ms();
1675    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1676    // A Kick is an authority action, so it cites its Grant like any other
1677    // (CORD-02 §5 / CORD-04 §5).
1678    let citation = required_authority_citation(community, &my_pk)?;
1679    let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1680    let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1681        .map_err(|e| e.to_string())?;
1682    if !session.is_valid() {
1683        return Err("account changed before send".to_string());
1684    }
1685    transport.publish(&wrap, &community.relays).await?;
1686    Ok(())
1687}
1688
1689/// A community's folded, delegation-authorized authority — the on-demand read
1690/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1691/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1692/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1693/// entity (floored locally but no head folded) before replacing it blind.
1694pub struct AuthorityView {
1695    pub roles: crate::community::roles::CommunityRoles,
1696    pub banned: std::collections::BTreeSet<String>,
1697    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1698    pub gapped: bool,
1699    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1700    pub floored: std::collections::BTreeSet<String>,
1701    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1702    pub head_entities: std::collections::BTreeSet<String>,
1703    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
1704    pub banned_at: std::collections::BTreeMap<String, u64>,
1705}
1706
1707/// Fetch + fold the community's current authority (CORD-04), paging older like
1708/// `follow_control` while the fold is gapped so a busy control plane can't push
1709/// the roster off the newest window. A fetch failure degrades fail-safe:
1710/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1711/// an outage, and a ban never lifts on withheld data.
1712pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1713    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1714    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1715        .unwrap_or_default()
1716        .into_iter()
1717        .filter(|(_, f)| f.0 == community.root_epoch.0)
1718        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1719        .collect();
1720    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1721
1722    let mut editions: Vec<ParsedEdition> = Vec::new();
1723    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1724    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1725    let mut oldest: Option<u64> = None;
1726    let mut until: Option<u64> = None;
1727    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1728    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1729    // error returns the stored bans (fail-safe), never an empty banlist that would
1730    // silently un-ban on withheld data.
1731    let mut a = fold_authority(community, &[], &floors);
1732    for _ in 0..FOLLOW_MAX_PAGES {
1733        // Quorum, DECLARED (the until→Full transport floor is gone): these
1734        // control reads tolerate a partial union — their fold semantics are
1735        // fail-safe on gaps (seeded banlists, withheld roster cache).
1736        let query = Query {
1737            kinds: vec![stream::KIND_WRAP],
1738            authors: vec![control.pk_hex()],
1739            until,
1740            limit: Some(FOLLOW_PAGE),
1741            evidence: crate::community::transport::Evidence::Quorum,
1742            ..Default::default()
1743        };
1744        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1745        let mut fresh = 0usize;
1746        for w in &wraps {
1747            if !seen_wraps.insert(w.id) {
1748                continue;
1749            }
1750            fresh += 1;
1751            let at = w.created_at.as_secs();
1752            if oldest.is_none_or(|o| at < o) {
1753                oldest = Some(at);
1754            }
1755            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1756                if seen.insert(ed.inner_id) {
1757                    editions.push(ed);
1758                }
1759            }
1760        }
1761        a = fold_authority(community, &editions, &floors);
1762        if !a.gapped || fresh == 0 {
1763            break;
1764        }
1765        until = oldest;
1766    }
1767    AuthorityView {
1768        roles: a.roles,
1769        banned: a.banned,
1770        gapped: a.gapped,
1771        floored: floors.keys().cloned().collect(),
1772        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1773        banned_at: a.banned_at,
1774    }
1775}
1776
1777/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
1778/// falls below `since_secs` (everything older is already held) or the plane is
1779/// exhausted. Returns the parsed events at/after the window plus the newest wrap
1780/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
1781///
1782/// PAGE bound rationale: a single 500-window silently drops a member whose Join
1783/// aged out (organic growth, or an insider flooding throwaway Joins), and
1784/// `refound_community` consumes the fold as its rekey recipient set — a dropped
1785/// member is SEVERED. Beyond this depth a community needs sharding (documented);
1786/// the granted-member union in [`fold_members`] is the consensus-complete
1787/// backstop regardless of Guestbook depth.
1788async fn fetch_guestbook_events<T: Transport + ?Sized>(
1789    transport: &T,
1790    community: &CommunityV2,
1791    since_secs: u64,
1792) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1793    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1794    const GB_PAGE: usize = 500;
1795    const GB_MAX_PAGES: usize = 12;
1796    let mut events = Vec::new();
1797    let mut newest: u64 = since_secs;
1798    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1799    let mut until: Option<u64> = None;
1800    let mut oldest: Option<u64> = None;
1801    for _ in 0..GB_MAX_PAGES {
1802        // Full: this set becomes the refound's recipient list — a member's
1803        // Join visible only on a minority relay must not be severed.
1804        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() };
1805        let wraps = transport.fetch(&query, &community.relays).await?;
1806        let mut fresh = 0usize;
1807        for wrap in &wraps {
1808            if !seen.insert(wrap.id) {
1809                continue;
1810            }
1811            fresh += 1;
1812            let at = wrap.created_at.as_secs();
1813            if oldest.is_none_or(|o| at < o) {
1814                oldest = Some(at);
1815            }
1816            if at > newest {
1817                newest = at;
1818            }
1819            // Older than the cursor window — already held; skip the decrypt.
1820            if at < since_secs {
1821                continue;
1822            }
1823            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1824                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1825                    events.push(ev);
1826                }
1827            }
1828        }
1829        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1830            break;
1831        }
1832        match oldest {
1833            Some(o) if o > 0 => until = Some(o),
1834            _ => break,
1835        }
1836    }
1837    Ok((events, newest))
1838}
1839
1840/// The shared membership fold: coalesce Guestbook events under the community's
1841/// authority (owner-supreme kicks, refounder snapshots), union observed authors
1842/// plus every roster grantee, subtract the banlist, and pin the proven owner.
1843/// One implementation, so the live and stored reads can't drift.
1844fn fold_members(
1845    community: &CommunityV2,
1846    events: &[guestbook::GuestbookEvent],
1847    mut observed: std::collections::BTreeMap<PublicKey, u64>,
1848    roles: &crate::community::roles::CommunityRoles,
1849    banlist: &std::collections::BTreeSet<PublicKey>,
1850    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1851) -> Result<Vec<PublicKey>, String> {
1852    let owner = community.owner()?;
1853    let owner_hex = owner.to_hex();
1854    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1855
1856    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1857    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1858    // even if their Join aged out of the Guestbook entirely and they never posted.
1859    // This is what keeps a Refounding from severing a lurking admin. `observed`
1860    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1861    // still removes a banned grantee whose grant wasn't yet stripped.
1862    for g in &roles.grants {
1863        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1864            observed.entry(pk).or_insert(0);
1865        }
1866    }
1867
1868    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1869    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
1870    // honored here, so a silent survivor stays in the memberlist across an owner refound
1871    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
1872    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
1873    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
1874    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
1875    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
1876    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1877    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
1878    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
1879    // kick equal).
1880    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1881        let actor_hex = actor.to_hex();
1882        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1883            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1884    };
1885    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1886    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1887    // The owner is a member by definition, independent of any fetched Join.
1888    if !banlist.contains(&owner) {
1889        members.insert(owner);
1890    }
1891    Ok(members.into_iter().collect())
1892}
1893
1894/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
1895///
1896/// This is the only sound basis for acting on a kick against ourselves. The
1897/// memberlist is the wrong question: it also folds the banlist, the ban marks and
1898/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
1899/// where the store starts empty while the control fold has already re-derived their
1900/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
1901/// only "what is the latest authorized entry for this npub", so a fresh Join
1902/// supersedes an old Kick and an empty store yields no verdict at all.
1903pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
1904    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1905    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
1906        return false;
1907    };
1908    let Ok(owner) = community.owner() else { return false };
1909    let owner_hex = owner.to_hex();
1910    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
1911    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1912    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1913        let actor_hex = actor.to_hex();
1914        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1915            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1916    };
1917    matches!(
1918        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
1919        Some(st) if st.verdict == guestbook::Verdict::Kicked
1920    )
1921}
1922
1923/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
1924/// from zero). The fetch straddles the network, so the session re-checks before
1925/// the store writes. Returns the events that were NEW to the store — the caller
1926/// surfaces them (presence lines) and refreshes on non-empty.
1927pub async fn sync_guestbook<T: Transport + ?Sized>(
1928    transport: &T,
1929    community: &CommunityV2,
1930    session: &SessionGuard,
1931) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1932    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1933    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1934    // Overlap one second so a same-second boundary event can't slip the cursor;
1935    // the rumor-id merge below dedups the re-fetched edge.
1936    let since = cursor.saturating_sub(1);
1937    let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1938    if !session.is_valid() {
1939        return Err("account changed during guestbook sync".to_string());
1940    }
1941    let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1942    let mut added = Vec::new();
1943    for ev in fresh {
1944        if !known.contains(&ev.rumor_id) {
1945            events.push(ev.clone());
1946            added.push(ev);
1947        }
1948    }
1949    if !added.is_empty() || newest > cursor {
1950        crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1951    }
1952    Ok(added)
1953}
1954
1955/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
1956/// Returns whether it was new.
1957pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1958    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1959    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1960    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1961        return Ok(false);
1962    }
1963    events.push(ev);
1964    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1965    Ok(true)
1966}
1967
1968/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
1969/// observed authors (the synced events DB), plus roster grantees, minus the
1970/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
1971/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
1972/// remains the authoritative walk — a refounding's rekey recipient set must
1973/// never trust a possibly-stale store.
1974pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1975    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1976    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1977    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1978    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1979        if let Ok(pk) = PublicKey::parse(&npub) {
1980            observed.insert(pk, last_active_secs.saturating_mul(1000));
1981        }
1982    }
1983    let roles = crate::db::community::get_community_roles(&cid_hex)?;
1984    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1985        .unwrap_or_default()
1986        .iter()
1987        .filter_map(|h| PublicKey::from_hex(h).ok())
1988        .collect();
1989    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
1990    // since this path never folds editions.
1991    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
1992        .unwrap_or_default()
1993        .into_iter()
1994        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
1995        .collect();
1996    fold_members(community, &events, observed, &roles, &banlist, &banned_at)
1997}
1998
1999/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2000/// ALWAYS a member (derived from the self-certifying community_id — no network,
2001/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2002/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2003/// member whose Join was lost still counts.
2004pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2005    let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2006    // Observed authors: fold each held channel's recent authorship (real author +
2007    // newest ms), so a member who posted but whose Join was lost is still counted.
2008    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2009    for ch in &community.channels {
2010        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2011            for f in &page {
2012                let e = observed.entry(f.event.opened().author).or_insert(0);
2013                *e = (*e).max(f.event.opened().at_ms);
2014            }
2015        }
2016    }
2017
2018    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2019    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2020    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2021    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2022    let authority = fetch_authority(transport, community).await;
2023    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2024    let banlist: std::collections::BTreeSet<PublicKey> =
2025        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2026    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2027    // editions still in its window, and a ban that aged out is exactly the one whose
2028    // pre-ban Join would phantom.
2029    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2030        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2031    )
2032    .unwrap_or_default()
2033    .into_iter()
2034    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2035    .collect();
2036    for (h, at) in &authority.banned_at {
2037        if let Ok(pk) = PublicKey::from_hex(h) {
2038            let slot = banned_at.entry(pk).or_insert(0);
2039            *slot = (*slot).max(*at);
2040        }
2041    }
2042    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2043}
2044
2045// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2046
2047/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2048/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2049/// past or present member resolves the same grave and a Refounding can never strand
2050/// it). The tombstone's presence IS the state; only the owner's seal counts.
2051/// Irreversible — on success the local hold is sealed read-only.
2052pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2053    let session = SessionGuard::capture();
2054    let signer = crate::signer::active_signer()?;
2055    let my_pk = me_pk()?;
2056    if community.owner()? != my_pk {
2057        return Err("only the owner can dissolve a community".to_string());
2058    }
2059    let at = now_ms() / 1000;
2060    let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2061    let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2062    if !session.is_valid() {
2063        return Err("account changed during dissolve".to_string());
2064    }
2065    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2066    transport.publish_durable(&wrap, &community.relays).await?;
2067    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2068    Ok(())
2069}
2070
2071/// Whether a valid owner-signed dissolution tombstone exists for this community on
2072/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
2073/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
2074/// owner-verified tombstone found is authoritative.
2075pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2076    let group = super::derive::dissolved_group_key(community.id());
2077    let query = Query {
2078        kinds: vec![stream::KIND_WRAP],
2079        authors: vec![group.pk_hex()],
2080        limit: Some(20),
2081        ..Default::default()
2082    };
2083    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2084        return false;
2085    };
2086    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2087}
2088
2089// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
2090
2091/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
2092/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
2093/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
2094/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
2095/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
2096/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
2097///
2098/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
2099/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
2100/// transient miss never strands a published rekey with a half-anchored plane.
2101pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2102    let session = SessionGuard::capture();
2103    let cid = community.id();
2104    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2105    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
2106    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2107        return Err("this community has been dissolved; it cannot be re-founded".to_string());
2108    }
2109    let signer = crate::signer::active_signer()?;
2110    let my_pk = me_pk()?;
2111    // Serialize with the follow worker for the whole rotation: the commit tail
2112    // whole-row-saves, and an unserialized concurrent follow could otherwise be
2113    // rolled back (or adopt a half-published sibling of this very rotation).
2114    let lock = super::realtime::follow_lock(cid);
2115    let _guard = lock.lock().await;
2116    // Reload the FRESHEST base state: a stale caller struct would address the rotation
2117    // under a superseded root (a base fork with no heal). The community_id is
2118    // self-certifying + stable, so re-loading by it is safe.
2119    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2120    let community = &fresh;
2121    let owner = community.owner()?;
2122
2123    // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
2124    // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
2125    // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
2126    // and the banlist authority fold: any admin holding BAN may re-found, checked
2127    // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
2128    // only the owner able to re-found.
2129    {
2130        let owner_hex = owner.to_hex();
2131        let me_hex = my_pk.to_hex();
2132        // Persisted (last-folded) roster — the receive side is authoritative, so
2133        // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
2134        // collapses to owner-only, which can only OVER-restrict a fresh admin whose
2135        // grant hasn't folded into their own DB (the caller's ban flow folds control
2136        // first). It can never grant authority no one has.
2137        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2138        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2139        let authorized = my_pk == owner
2140            || (!banned.contains(&me_hex)
2141                && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2142                && removed.iter().all(|t| {
2143                    roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2144                }));
2145        if !authorized {
2146            return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2147        }
2148    }
2149
2150    // Fold the current roster: the opened editions are reused for the compaction (their
2151    // seals re-wrap under the new epoch), and the roster gates which admin-authored
2152    // heads carry forward.
2153    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2154        .into_iter()
2155        .filter(|(_, f)| f.0 == community.root_epoch.0)
2156        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2157        .collect();
2158    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2159    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
2160    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
2161    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
2162    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
2163    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
2164    // at the new epoch a fresh joiner bootstraps.
2165    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2166    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2167    let mut oldest: Option<u64> = None;
2168    let mut until: Option<u64> = None;
2169    // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
2170    // first-ever Banlist published while we were away) is invisible to a
2171    // coverage test, so stopping there could compact it away.
2172    let mut truncated = false;
2173    for page in 0..COMPACT_MAX_PAGES {
2174        // Full: compaction re-wraps the head set it can SEE — a control
2175        // edition (a ban head) reachable only on a minority relay must not be
2176        // compacted away by a partial union.
2177        let query = Query {
2178            kinds: vec![stream::KIND_WRAP],
2179            authors: vec![current_control.pk_hex()],
2180            until,
2181            limit: Some(FOLLOW_PAGE),
2182            evidence: crate::community::transport::Evidence::Full,
2183            ..Default::default()
2184        };
2185        let wraps = transport.fetch(&query, &community.relays).await?;
2186        let mut fresh = 0usize;
2187        for w in &wraps {
2188            if !seen_wraps.insert(w.id) {
2189                continue;
2190            }
2191            fresh += 1;
2192            let at = w.created_at.as_secs();
2193            if oldest.is_none_or(|o| at < o) {
2194                oldest = Some(at);
2195            }
2196            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2197                opened.push(parsed);
2198            }
2199        }
2200        if fresh == 0 {
2201            // `until` is inclusive: a FULL page with nothing new is a same-second
2202            // wall no cursor steps past, so older editions stay unreachable. A
2203            // short page is simply the end of the plane.
2204            truncated = wraps.len() >= FOLLOW_PAGE;
2205            break;
2206        }
2207        until = oldest;
2208        if page + 1 == COMPACT_MAX_PAGES {
2209            truncated = true;
2210        }
2211    }
2212    if truncated {
2213        return Err(
2214            "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(),
2215        );
2216    }
2217
2218    let prev_epoch = community.root_epoch;
2219    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2220    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2221    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
2222    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
2223    // it can't double-mint two roots a receiver's correlation dedup would collapse into
2224    // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
2225    // this DB write — re-check so a mid-fetch swap can't archive into another account.
2226    if !session.is_valid() {
2227        return Err("account changed during re-founding compaction".to_string());
2228    }
2229    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2230    let new_control = control_group_key(&new_root, cid, new_epoch);
2231    let at = now_ms();
2232    let at_secs = at / 1000;
2233
2234    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
2235    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
2236    // including entities the metadata/roster folds don't touch (the invite Registry
2237    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
2238    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
2239    use std::collections::BTreeMap;
2240    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2241    for (i, (e, _)) in opened.iter().enumerate() {
2242        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2243    }
2244    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2245    for (floor_key, floor) in &floors {
2246        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
2247        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
2248        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
2249        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
2250        // edition chaining onto the floor, which the tip would carry and honest folders
2251        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
2252        // the refounding. Abort if the committed head isn't served (fail-closed).
2253        let head_idx = by_eid
2254            .get(floor_key)
2255            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2256        let Some(head_idx) = head_idx else {
2257            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2258        };
2259        let (head_ed, head_os) = &opened[head_idx];
2260        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2261        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2262        carried.push((h, rewrapped));
2263    }
2264    if !session.is_valid() {
2265        return Err("account changed during re-founding acquire".to_string());
2266    }
2267
2268    // Recipients: the current members minus `removed`, plus me (multi-device).
2269    let members = memberlist(transport, community).await?;
2270    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2271    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2272    if !recipients.iter().any(|p| *p == my_pk) {
2273        recipients.push(my_pk);
2274    }
2275
2276    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
2277    let mut base_blobs = Vec::new();
2278    for r in &recipients {
2279        base_blobs.push(
2280            super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2281                .await
2282                .map_err(|e| e.to_string())?,
2283        );
2284    }
2285    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2286    let base_chunks =
2287        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())
2288            .await
2289            .map_err(|e| e.to_string())?;
2290
2291    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
2292    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
2293    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2294    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2295    for ch in &community.channels {
2296        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2297        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2298        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
2299        // retry-idempotency as the base root. The base-rekey signing above is a bunker
2300        // round-trip; re-check before this per-channel DB write straddles it.
2301        if !session.is_valid() {
2302            return Err("account changed during re-founding channel prepare".to_string());
2303        }
2304        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)?;
2305        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2306        let mut ch_blobs = Vec::new();
2307        for r in &recipients {
2308            ch_blobs.push(
2309                super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2310                    .await
2311                    .map_err(|e| e.to_string())?,
2312            );
2313        }
2314        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2315        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())
2316            .await
2317            .map_err(|e| e.to_string())?;
2318        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2319        channel_chunk_sets.push(ch_chunks);
2320    }
2321    if !session.is_valid() {
2322        return Err("account changed during re-founding prepare".to_string());
2323    }
2324
2325    // COMMIT (durable publishes only — all fetching is done). Base rekey first
2326    // (delivers the new root), then channel rekeys, then the compacted control.
2327    for c in &base_chunks {
2328        transport.publish_durable(c, &community.relays).await?;
2329    }
2330    for set in &channel_chunk_sets {
2331        for c in set {
2332            transport.publish_durable(c, &community.relays).await?;
2333        }
2334    }
2335    for (_, wrap) in &carried {
2336        transport.publish_durable(wrap, &community.relays).await?;
2337    }
2338    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
2339    // it; an omitted member heals by publishing their own Join).
2340    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2341    let snap_id = crate::community::random_32();
2342    for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2343        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2344            let _ = transport.publish(&wrap, &community.relays).await;
2345        }
2346    }
2347
2348    // COMMIT locally, only now that the new root + compacted plane are on relays.
2349    if !session.is_valid() {
2350        return Err("account changed during re-founding commit".to_string());
2351    }
2352    if crate::db::community::community_protocol(cid)?.is_none() {
2353        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
2354    }
2355    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
2356    // never leave the base root advanced while the channel keys lag (which would
2357    // re-derive the channel rekey address under the wrong root and orphan them).
2358    let mut updated = community.clone();
2359    updated.community_root = new_root;
2360    updated.root_epoch = new_epoch;
2361    for (id, key, ep) in &channel_updates {
2362        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2363            c.key = Some(*key);
2364            c.epoch = *ep;
2365        }
2366    }
2367    crate::db::community::save_community_v2(&updated)?;
2368    // Archive the new epoch key + confirm the monotonic base head (the root was already
2369    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
2370    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
2371    // compacted control on the next follow, so they self-heal.
2372    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2373    for (h, _) in &carried {
2374        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2375    }
2376    // Re-subscribe NOW: the rotation changed every plane author, and the live sub
2377    // still carries the OLD epoch's set. Members adopt via the follow worker
2378    // (which refreshes); the REFOUNDER has no such path — without this, the very
2379    // client that performed the ban goes deaf to the new epoch (a rejoin lands on
2380    // the relays and never arrives live).
2381    if let Some(client) = crate::state::nostr_client() {
2382        super::realtime::refresh_subscription(&client).await;
2383    }
2384    // Refresh any live public links so their bundles carry the NEW root behind the
2385    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
2386    // so retry a transient failure — a stranded link lands a new joiner on the dead
2387    // pre-refound epoch, and there's no other trigger to heal it before the next
2388    // refounding. A persistent failure is logged (refound already succeeded).
2389    for attempt in 0..3u8 {
2390        match refresh_public_links(transport, &updated).await {
2391            Ok(()) => break,
2392            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
2393            Err(e) if attempt == 2 => {
2394                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2395            }
2396            Err(_) => continue,
2397        }
2398    }
2399    Ok(updated)
2400}
2401
2402/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
2403/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
2404/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
2405/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
2406/// the two sets are DECOUPLED:
2407///
2408/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
2409///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
2410///   set at {owner} also dodges the 120-blob rotation cap for large communities.
2411/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
2412///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
2413///
2414/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
2415/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
2416/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
2417pub async fn refound_at_birth<T: Transport + ?Sized>(
2418    transport: &T,
2419    community: &CommunityV2,
2420    snapshot_members: &[PublicKey],
2421) -> Result<CommunityV2, String> {
2422    let session = SessionGuard::capture();
2423    let cid = community.id();
2424    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2425    // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
2426    // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
2427    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2428        return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2429    }
2430    let signer = crate::signer::active_signer()?;
2431    let my_pk = me_pk()?;
2432    if my_pk != community.owner()? {
2433        return Err("only the owner can birth-refound the migration twin".to_string());
2434    }
2435    let lock = super::realtime::follow_lock(cid);
2436    let _guard = lock.lock().await;
2437    let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2438    // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
2439    // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
2440    // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
2441    // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
2442    // unexpected (nothing else rotates a mid-migration twin).
2443    if community.root_epoch.0 == 1 {
2444        return Ok(community);
2445    }
2446    if community.root_epoch.0 != 0 {
2447        return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2448    }
2449    let community = &community;
2450
2451    // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
2452    // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
2453    // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
2454    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2455        .into_iter()
2456        .filter(|(_, f)| f.0 == community.root_epoch.0)
2457        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2458        .collect();
2459    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2460    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2461    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2462    let mut oldest: Option<u64> = None;
2463    let mut until: Option<u64> = None;
2464    // Exhaustion, not coverage — see the sibling read in `refound_community`.
2465    let mut truncated = false;
2466    for page in 0..COMPACT_MAX_PAGES {
2467        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() };
2468        let wraps = transport.fetch(&query, &community.relays).await?;
2469        let mut fresh = 0usize;
2470        for w in &wraps {
2471            if !seen_wraps.insert(w.id) { continue; }
2472            fresh += 1;
2473            let at = w.created_at.as_secs();
2474            if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2475            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2476                opened.push(parsed);
2477            }
2478        }
2479        if fresh == 0 {
2480            truncated = wraps.len() >= FOLLOW_PAGE;
2481            break;
2482        }
2483        until = oldest;
2484        if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2485    }
2486    if truncated {
2487        return Err(
2488            "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(),
2489        );
2490    }
2491
2492    let prev_epoch = community.root_epoch; // 0
2493    let new_epoch = Epoch(1);
2494    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2495    if !session.is_valid() {
2496        return Err("account changed during birth-refound compaction".to_string());
2497    }
2498    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2499    let new_control = control_group_key(&new_root, cid, new_epoch);
2500    let at = now_ms();
2501    let at_secs = at / 1000;
2502
2503    use std::collections::BTreeMap;
2504    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2505    for (i, (e, _)) in opened.iter().enumerate() {
2506        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2507    }
2508    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2509    for (floor_key, floor) in &floors {
2510        let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2511        let Some(head_idx) = head_idx else {
2512            return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2513        };
2514        let (head_ed, head_os) = &opened[head_idx];
2515        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2516        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2517        carried.push((h, rewrapped));
2518    }
2519    if !session.is_valid() {
2520        return Err("account changed during birth-refound acquire".to_string());
2521    }
2522
2523    // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the carrier's `m`).
2524    let base_blobs = vec![
2525        super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2526            .await
2527            .map_err(|e| e.to_string())?,
2528    ];
2529    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2530    let base_chunks =
2531        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())
2532            .await
2533            .map_err(|e| e.to_string())?;
2534    if !session.is_valid() {
2535        return Err("account changed during birth-refound prepare".to_string());
2536    }
2537
2538    // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
2539    for c in &base_chunks {
2540        transport.publish_durable(c, &community.relays).await?;
2541    }
2542    for (_, wrap) in &carried {
2543        transport.publish_durable(wrap, &community.relays).await?;
2544    }
2545    // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
2546    // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
2547    // live members heal via their own Join if a chunk drops), a seeded-never-landed member
2548    // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
2549    // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
2550    // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
2551    // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
2552    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2553    let snap_id = crate::community::random_32();
2554    let snapshot_wraps: Vec<Event> = {
2555        let mut out = Vec::new();
2556        for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2557            let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2558                .await
2559                .map_err(|e| format!("seal birth snapshot: {e}"))?;
2560            out.push(wrap);
2561        }
2562        out
2563    };
2564    for wrap in &snapshot_wraps {
2565        transport.publish_durable(wrap, &community.relays).await?;
2566    }
2567    // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
2568    // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
2569    // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
2570    // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
2571    // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
2572    // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
2573    // banlist from the expected set. The real caller never seeds a banned member, but the
2574    // arbitrary-`snapshot_members` API must not be able to wedge on one.
2575    let verify_view = {
2576        let mut v = community.clone();
2577        v.community_root = new_root;
2578        v.root_epoch = new_epoch;
2579        v
2580    };
2581    let expected: Vec<PublicKey> = {
2582        let banlist = fetch_authority(transport, &verify_view).await.banned;
2583        snapshot_members.iter().copied()
2584            .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2585            .collect()
2586    };
2587    if !expected.is_empty() {
2588        let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2589        let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2590        if missing > 0 {
2591            return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2592        }
2593    }
2594
2595    // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
2596    if !session.is_valid() {
2597        return Err("account changed during birth-refound commit".to_string());
2598    }
2599    if crate::db::community::community_protocol(cid)?.is_none() {
2600        return Ok(community.clone());
2601    }
2602    let mut updated = community.clone();
2603    updated.community_root = new_root;
2604    updated.root_epoch = new_epoch;
2605    crate::db::community::save_community_v2(&updated)?;
2606    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2607    for (h, _) in &carried {
2608        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2609    }
2610    Ok(updated)
2611}
2612
2613/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
2614/// already archived from a prior (aborted) attempt — so a retried Refounding re-
2615/// delivers the SAME key at the same epoch/address instead of double-minting two roots
2616/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
2617/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
2618/// sentinel for a base rotation, else the channel_id hex.
2619fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2620    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2621        return Ok(existing);
2622    }
2623    let fresh = crate::community::random_32();
2624    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2625    Ok(fresh)
2626}
2627
2628// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
2629
2630/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
2631/// icon (a rehydrating device folds it from the Control Plane), never the link
2632/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
2633fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2634    let hex = crate::simd::hex::bytes_to_hex_32;
2635    let channels = community
2636        .channels
2637        .iter()
2638        .filter(|c| c.private)
2639        .filter_map(|c| {
2640            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
2641        })
2642        .collect();
2643    super::list::JoinMaterial {
2644        community_id: hex(&community.identity.community_id.0),
2645        owner: hex(&community.identity.owner_xonly),
2646        owner_salt: hex(&community.identity.owner_salt),
2647        community_root: hex(&community.community_root),
2648        root_epoch: community.root_epoch.0,
2649        channels,
2650        relays: community.relays.clone(),
2651        name: community.name.clone(),
2652        extra: Default::default(),
2653    }
2654}
2655
2656/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
2657/// (the material IS the membership subset of a bundle). The owner root is still
2658/// verified over the network before the community is trusted (accept_bundle).
2659fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2660    let channels = jm
2661        .channels
2662        .iter()
2663        .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
2664        .collect();
2665    CommunityInvite {
2666        community_id: jm.community_id.clone(),
2667        owner: jm.owner.clone(),
2668        owner_salt: jm.owner_salt.clone(),
2669        community_root: jm.community_root.clone(),
2670        root_epoch: jm.root_epoch,
2671        channels,
2672        relays: jm.relays.clone(),
2673        name: jm.name.clone(),
2674        icon: None,
2675        expires_at: None,
2676        creator_npub: None,
2677        label: None,
2678        extra: Default::default(),
2679    }
2680}
2681
2682/// The union of every held v2 community's relays — where this account's 13302 list
2683/// lives (a fresh device that opens any held community reaches the same set).
2684fn held_v2_relays() -> Vec<String> {
2685    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2686    if let Ok(ids) = crate::db::community::list_community_ids() {
2687        for id in ids {
2688            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2689                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2690                    set.extend(c.relays);
2691                }
2692            }
2693        }
2694    }
2695    set.into_iter().collect()
2696}
2697
2698/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
2699/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
2700/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
2701/// must NOT drive a replaceable-event write from a failed read — it would clobber
2702/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
2703async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2704    let signer = crate::signer::active_signer()?;
2705    let my_pk = me_pk()?;
2706    let query = Query {
2707        kinds: vec![super::kind::COMMUNITY_LIST],
2708        authors: vec![my_pk.to_hex()],
2709        limit: Some(4),
2710        ..Default::default()
2711    };
2712    let events = transport.fetch(&query, relays).await?;
2713    let mut best: Option<(u64, super::list::CommunityList)> = None;
2714    for e in events {
2715        if let Ok(l) = super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2716            let at = e.created_at.as_secs();
2717            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
2718                best = Some((at, l));
2719            }
2720        }
2721    }
2722    Ok(best.map(|(_, l)| l))
2723}
2724
2725/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
2726/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
2727/// `just_joined` is the community THIS call is recording a create/join for — the
2728/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
2729/// (a deliberate re-join resurrects). Every OTHER held community that the remote
2730/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
2731/// because we joined something else — the W1 resurrection hole). Idempotent;
2732/// best-effort — a list-publish failure never fails the membership change itself.
2733/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
2734/// skipped without failing the caller (a failed remote fetch — see below). Callers that
2735/// need the membership to actually land use [`republish_community_list_durable`].
2736pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2737    let session = SessionGuard::capture();
2738    let signer = crate::signer::active_signer()?;
2739    let my_pk = me_pk()?;
2740    let relays = held_v2_relays();
2741    if relays.is_empty() {
2742        return Ok(false); // nothing held → nothing to sync
2743    }
2744    // A FAILED remote fetch must not drive this replaceable-event write: publishing
2745    // a list built without the remote seeds would drop older-epoch backfill anchors
2746    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
2747    let remote = match fetch_community_list(transport, &relays).await {
2748        Ok(r) => r.unwrap_or_default(),
2749        Err(e) => {
2750            // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
2751            // remote seeds drops backfill anchors), but the membership this call was meant
2752            // to record is now simply unrecorded. A join that lands here leaves a community
2753            // held locally with no list entry — and if it also carries an older tombstone,
2754            // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
2755            crate::log_warn!(
2756                "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2757                e,
2758                just_joined
2759                    .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2760                    .unwrap_or_default()
2761            );
2762            return Ok(false);
2763        }
2764    };
2765    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2766    let now = now_ms();
2767    let mut local = super::list::CommunityList::default();
2768    for id in crate::db::community::list_community_ids()? {
2769        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2770            continue;
2771        }
2772        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2773        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2774        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2775        // A held community the remote has tombstoned (a sibling device left it) that
2776        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
2777        // different community would silently undo the leave everywhere.
2778        //
2779        // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
2780        // reached the list (this publish is best-effort — a failed remote fetch
2781        // silently skips it) leaves a tombstone with no entry, and nothing can ever
2782        // out-rank it again: every boot the list sync reads "removed", tears the
2783        // community down, the rejoin re-adds it, and it loops forever. Our own hold
2784        // is first-hand evidence of membership, so let it settle the tie by the same
2785        // add-vs-remove rule the list already uses everywhere else.
2786        let tombstoned_at = remote
2787            .tombstones
2788            .iter()
2789            .find(|t| t.community_id == cid_hex)
2790            .map(|t| t.removed_at)
2791            .unwrap_or(0);
2792        let held_since = c.created_at_ms;
2793        if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2794            crate::log_warn!(
2795                "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2796                &cid_hex[..8], tombstoned_at, held_since
2797            );
2798            continue;
2799        }
2800        // Keep an already-live entry's add time (no churn); the joined community (or a
2801        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
2802        // that outlived a tombstone re-asserts itself at its own join time, which is
2803        // already newer than the removal.
2804        let added_at = if remote.is_live(&cid_hex) && !is_join {
2805            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2806        } else if !is_join && tombstoned_at > 0 {
2807            held_since
2808        } else {
2809            now
2810        };
2811        let jm = join_material(&c);
2812        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
2813    }
2814    let merged = remote.merge(&local);
2815    merged.assert_fits().map_err(|e| e.to_string())?;
2816    let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
2817    if !session.is_valid() {
2818        return Err("account changed during community-list publish".to_string());
2819    }
2820    if let Err(e) = transport.publish(&event, &relays).await {
2821        crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
2822        return Err(e);
2823    }
2824    Ok(true)
2825}
2826
2827/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
2828/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
2829/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
2830/// instead of spinning.
2831const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
2832
2833/// Record a membership across devices DURABLY: retry in the background until the list
2834/// actually lands.
2835///
2836/// [`republish_community_list`] must never fail a join, and it deliberately publishes
2837/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
2838/// other devices' entries). One shot at that means a relay blip during a join leaves the
2839/// membership unrecorded until the user happens to join something else — and if a stale
2840/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
2841///
2842/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
2843/// generic fn directly). The `SessionGuard` is captured BEFORE the spawn and re-checked
2844/// before every attempt, so an account swap mid-backoff can't publish A's list from B.
2845pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
2846    if crate::state::nostr_client().is_none() {
2847        return;
2848    }
2849    let session = SessionGuard::capture();
2850    tokio::spawn(async move {
2851        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
2852            if !session.is_valid() {
2853                return;
2854            }
2855            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2856            match republish_community_list(&transport, just_joined.as_ref()).await {
2857                Ok(true) => {
2858                    if attempt > 0 {
2859                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
2860                    }
2861                    return;
2862                }
2863                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
2864                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
2865            }
2866            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
2867        }
2868        crate::log_warn!(
2869            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
2870            LIST_REPUBLISH_BACKOFF_SECS.len()
2871        );
2872    });
2873}
2874
2875/// Record a permanent leave tombstone for `community_id` in the 13302, published to
2876/// `relays` (the leaving community's own, since it's about to be deleted locally).
2877async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
2878    let signer = crate::signer::active_signer()?;
2879    let my_pk = me_pk()?;
2880    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
2881    // A failed fetch here would drop other communities' entries (only the
2882    // tombstone would survive); preserve them by bailing — the leave re-records
2883    // on the next attempt, and the local teardown already happened.
2884    let mut doc = match fetch_community_list(transport, relays).await {
2885        Ok(d) => d.unwrap_or_default(),
2886        Err(e) => return Err(e),
2887    };
2888    let now = now_ms();
2889    doc.tombstones.retain(|t| t.community_id != cid_hex);
2890    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
2891    doc.assert_fits().map_err(|e| e.to_string())?;
2892    let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
2893    transport.publish(&event, relays).await
2894}
2895
2896/// Sync memberships from the 13302 across devices: fetch this account's list from
2897/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
2898/// a fresh device), and JOIN every live entry not already held — reconstructing the
2899/// community from its join material and re-verifying the owner root. Returns the
2900/// newly-rehydrated communities (so the caller can subscribe + notify).
2901/// What one Community-List sync changed locally.
2902pub struct ListSyncOutcome {
2903    /// Communities newly adopted from the list (already persisted + chat-registered).
2904    pub joined: Vec<CommunityV2>,
2905    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
2906    ///
2907    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
2908    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
2909    /// and it can't look them up afterwards. Deleting the community while leaving its chat
2910    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
2911    pub removed: Vec<(String, Vec<String>)>,
2912}
2913
2914pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
2915    let session = SessionGuard::capture();
2916    let mut relays = held_v2_relays();
2917    relays.extend(bootstrap_relays.iter().cloned());
2918    relays.sort();
2919    relays.dedup();
2920    if relays.is_empty() {
2921        return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
2922    }
2923    let list = match fetch_community_list(transport, &relays).await {
2924        Ok(Some(l)) => l,
2925        Ok(None) | Err(_) => return Ok(ListSyncOutcome { joined: vec![], removed: vec![] }),
2926    };
2927    // Receive-side teardown (the counterpart to the republish tombstone guard):
2928    // a community this device still holds but the synced list shows TOMBSTONED (a
2929    // sibling device left it) and NOT live gets torn down here, so a leave on one
2930    // device propagates to the others. A re-join would have re-added it live
2931    // (beating the tombstone), so is_live short-circuits the honest case.
2932    let mut removed: Vec<(String, Vec<String>)> = Vec::new();
2933    for t in &list.tombstones {
2934        if list.is_live(&t.community_id) {
2935            continue;
2936        }
2937        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2938        let id = crate::community::CommunityId(cid);
2939        let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
2940            continue; // not held — nothing to tear down
2941        };
2942        // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
2943        // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
2944        // this would then delete the community on every sync. So let the LOCAL hold break the
2945        // tie too — a hold created after the removal IS the rejoin, whether or not its entry
2946        // ever reached the list. Same rule the v1 sweep uses.
2947        if held.created_at_ms > t.removed_at {
2948            crate::log_warn!(
2949                "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
2950                &t.community_id[..8], t.removed_at, held.created_at_ms
2951            );
2952            continue;
2953        }
2954        if !session.is_valid() {
2955            return Err("account changed during community-list sync".to_string());
2956        }
2957        let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
2958        let _ = crate::db::community::delete_community(&t.community_id);
2959        removed.push((t.community_id.clone(), channel_ids));
2960    }
2961    let mut joined = Vec::new();
2962    for entry in list.live_entries() {
2963        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2964        if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2965            continue; // already held
2966        }
2967        if !session.is_valid() {
2968            return Err("account changed during community-list sync".to_string());
2969        }
2970        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
2971        // and seeds floors. NO Guestbook Join: this device is receiving keys the
2972        // account already holds elsewhere — the membership was announced when it
2973        // actually joined, and a key sync is not a membership event.
2974        let bundle = material_to_invite(&entry.current);
2975        if let Ok(community) = accept_bundle(transport, &session, &bundle, None, false).await {
2976            joined.push(community);
2977        }
2978    }
2979    Ok(ListSyncOutcome { joined, removed })
2980}
2981
2982// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
2983
2984/// Publish one control edition (a role, grant, banlist, community-metadata, or
2985/// channel-metadata edit) at the next version for its entity, chaining `prev` from
2986/// our held head, and advance our local floor. Authority is enforced by every
2987/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
2988/// requires only a valid local signer; a well-behaved client checks its own rank
2989/// first, but a reader drops an unauthorized edition regardless.
2990/// This actor's authority citation for a control edition (CORD-04 §5): the head
2991/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
2992///
2993/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
2994/// at least this Grant, then resolves rank against its CURRENT roster, so a
2995/// demoted admin is never grandfathered by an old-but-once-valid citation.
2996///
2997/// `None` for the owner (supreme, rank comes from the community id) and `None`
2998/// when no Grant head is held — an actor who cannot cite has no rank to claim,
2999/// and the edition is dropped by a conforming reader either way.
3000/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
3001/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
3002/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
3003/// cited version with the cited hash at the tip — else fail closed, because
3004/// honoring an action whose authority we can't confirm is exactly how a demoted
3005/// moderator keeps moderating.
3006///
3007/// Completeness only: the permission + outrank is the separate roster check, so a
3008/// since-demoted actor is refused there (refuse-superseded). An action citing a
3009/// version we haven't synced parks and is re-judged on the next roster sync — the
3010/// sync path can't escalate to a blocking fetch.
3011pub(super) fn citation_is_synced(
3012    cid_hex: &str,
3013    owner_hex: &str,
3014    actor_hex: &str,
3015    citation: Option<&crate::community::edition::AuthorityCitation>,
3016) -> bool {
3017    if owner_hex == actor_hex {
3018        return true;
3019    }
3020    if citation.is_none() {
3021        return false;
3022    }
3023    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3024    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3025    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3026        &crate::community::CommunityId(cid_bytes),
3027        &actor_bytes,
3028    ));
3029    let head: Vec<crate::community::roster::EntityHead> =
3030        crate::db::community::get_edition_head(cid_hex, &grant_hex)
3031            .ok()
3032            .flatten()
3033            .map(|(version, self_hash)| crate::community::roster::EntityHead {
3034                entity_hex: grant_hex.clone(),
3035                version,
3036                self_hash,
3037                inner_id: [0u8; 32],
3038                citation: None,
3039            })
3040            .into_iter()
3041            .collect();
3042    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3043}
3044
3045/// [`my_authority_citation`], but refusing to emit an action every reader will
3046/// drop (CORD-04 §5: an uncited non-owner action is not honored).
3047///
3048/// The citation is built from PERSISTED heads, which only `follow_control` writes
3049/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
3050/// otherwise publish uncited and have the action silently vanish on every client,
3051/// with nothing shown locally. Failing here turns that into one retryable error.
3052fn required_authority_citation(
3053    community: &CommunityV2,
3054    actor: &PublicKey,
3055) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3056    if community.owner().ok().as_ref() == Some(actor) {
3057        return Ok(None); // supreme, cites nothing
3058    }
3059    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3060        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3061    })
3062}
3063
3064fn my_authority_citation(
3065    community: &CommunityV2,
3066    actor: &PublicKey,
3067) -> Option<crate::community::edition::AuthorityCitation> {
3068    if community.owner().ok().as_ref() == Some(actor) {
3069        return None;
3070    }
3071    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3072    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3073    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3074    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3075        .ok()
3076        .flatten()
3077        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3078}
3079
3080/// Refuse a root-derived write whose in-hand struct predates a rotation.
3081///
3082/// A Ban's refound buries the old root while the caller's `CommunityV2` still
3083/// points at it; publishing there lands on a plane nobody folds — the action
3084/// "succeeds" and silently never happened (an unban that doesn't unban, an
3085/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
3086/// the caller reload and retry against the living root.
3087fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3088    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3089    match crate::db::community::get_server_root_epoch(&cid_hex)? {
3090        Some(held) if held != community.root_epoch.0 => Err(format!(
3091            "the community re-founded mid-action (epoch {} -> {held}); retry",
3092            community.root_epoch.0
3093        )),
3094        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
3095    }
3096}
3097
3098async fn publish_control_edition<T: Transport + ?Sized>(
3099    transport: &T,
3100    community: &CommunityV2,
3101    session: &SessionGuard,
3102    vsk: &str,
3103    entity_id: &[u8; 32],
3104    content: &str,
3105) -> Result<(), String> {
3106    assert_current_root(community)?;
3107    let signer = crate::signer::active_signer()?;
3108    let my_pk = me_pk()?;
3109    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3110    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3111    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3112    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3113        Some((v, h)) => (v + 1, Some(h)),
3114        None => (1, None),
3115    };
3116    // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
3117    // under. Computed here rather than passed in — the citation is a property of
3118    // WHO IS ACTING, identical for every entity kind, so deciding it per call
3119    // site is nine chances to forget (and nine were, silently: every site passed
3120    // None). The owner cites nothing; their rank is the community id itself.
3121    let citation = required_authority_citation(community, &my_pk)?;
3122    let at = now_ms() / 1000;
3123    let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3124    let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3125    if !session.is_valid() {
3126        return Err("account changed before control publish".to_string());
3127    }
3128    transport.publish(&wrap, &community.relays).await?;
3129    // Advance our own floor so a follow-up edit chains from this head and refuse-
3130    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
3131    // Re-check the session AFTER the publish await: a swap mid-publish means the
3132    // pool now points at another account's DB — skipping is safe (the next own
3133    // edit rebuilds the same head from the relay's copy).
3134    if !session.is_valid() {
3135        return Ok(());
3136    }
3137    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3138        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3139    }
3140    Ok(())
3141}
3142
3143/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
3144/// rename or permission change is a versioned edit of the same id. Gated on the
3145/// reader side by `MANAGE_ROLES` + outrank.
3146pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3147    let session = SessionGuard::capture();
3148    super::roles::validate_role(role)?;
3149    let content = super::roles::role_content_json(role)?;
3150    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3151    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3152}
3153
3154/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
3155/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
3156/// member.
3157pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3158    let session = SessionGuard::capture();
3159    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3160    let content = super::roles::grant_content_json(&grant)?;
3161    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3162    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3163}
3164
3165/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
3166/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
3167/// every device, so concurrent grants converge as editions of ONE entity instead
3168/// of forking two Admin roles.
3169pub async fn ensure_admin_role<T: Transport + ?Sized>(
3170    transport: &T,
3171    community: &CommunityV2,
3172    view: &AuthorityView,
3173    create_if_missing: bool,
3174) -> Result<Option<String>, String> {
3175    use crate::community::roles::{Permissions, Role, RoleScope};
3176    if let Some(r) = view
3177        .roles
3178        .roles
3179        .iter()
3180        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
3181    {
3182        return Ok(Some(r.role_id.clone()));
3183    }
3184    if !create_if_missing {
3185        return Ok(None);
3186    }
3187    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3188    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3189    set_role(transport, community, &Role::admin(role_id.clone())).await?;
3190    Ok(Some(role_id))
3191}
3192
3193/// Grant the @admin role (minting it deterministically when absent), MERGED into
3194/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
3195/// blind push would erase their other roles. Owner-only: the position-1 Admin is
3196/// manageable only by position 0 (an equal never outranks it), and refusing
3197/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
3198/// entity from advancing this device's own floor onto a head readers reject.
3199pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3200    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
3201    // downstream publish's own (post-swap) guard write account A's floor into B.
3202    let session = SessionGuard::capture();
3203    let my_pk = me_pk()?;
3204    if my_pk != community.owner()? {
3205        return Err("only the community owner can grant @admin".to_string());
3206    }
3207    let view = fetch_authority(transport, community).await;
3208    if !session.is_valid() {
3209        return Err("account changed during grant".to_string());
3210    }
3211    let member_hex = member.to_hex();
3212    require_grant_head(community, &view, &member_hex)?;
3213    let role_id = ensure_admin_role(transport, community, &view, true)
3214        .await?
3215        .expect("create_if_missing yields an id");
3216    let mut role_ids = view
3217        .roles
3218        .grants
3219        .iter()
3220        .find(|g| g.member == member_hex)
3221        .map(|g| g.role_ids.clone())
3222        .unwrap_or_default();
3223    if role_ids.contains(&role_id) {
3224        return Ok(()); // already admin — don't bump the grant edition for nothing.
3225    }
3226    role_ids.push(role_id);
3227    grant_roles(transport, community, member, role_ids).await
3228}
3229
3230/// Strip the @admin role from the member's grant, preserving their other roles.
3231/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
3232pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3233    let session = SessionGuard::capture();
3234    let my_pk = me_pk()?;
3235    if my_pk != community.owner()? {
3236        return Err("only the community owner can revoke @admin".to_string());
3237    }
3238    let view = fetch_authority(transport, community).await;
3239    if !session.is_valid() {
3240        return Err("account changed during revoke".to_string());
3241    }
3242    let member_hex = member.to_hex();
3243    require_grant_head(community, &view, &member_hex)?;
3244    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3245        return Ok(()); // no admin role exists — nothing to revoke.
3246    };
3247    let mut role_ids = view
3248        .roles
3249        .grants
3250        .iter()
3251        .find(|g| g.member == member_hex)
3252        .map(|g| g.role_ids.clone())
3253        .unwrap_or_default();
3254    let before = role_ids.len();
3255    role_ids.retain(|r| r != &role_id);
3256    if role_ids.len() == before {
3257        return Ok(());
3258    }
3259    grant_roles(transport, community, member, role_ids).await
3260}
3261
3262/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
3263/// locally but no head folded (withheld / evicted): a blind push at that point
3264/// would erase their other roles at a higher version.
3265fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3266    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3267        return Err("malformed member key".to_string());
3268    };
3269    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3270    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3271        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3272    }
3273    Ok(())
3274}
3275
3276/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
3277/// whole list on every edit. Gated on the reader side by `BAN`.
3278pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3279    let session = SessionGuard::capture();
3280    super::roles::validate_banlist(banned)?;
3281    let content = super::roles::banlist_content_json(banned)?;
3282    let eid = super::derive::banlist_locator(community.id());
3283    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await
3284}
3285
3286/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
3287/// `MANAGE_METADATA`.
3288pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3289    let session = SessionGuard::capture();
3290    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3291    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3292    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3293}
3294
3295/// Persist a freshly-published icon/banner onto the held row and return the fresh
3296/// row. Reloads under the community's follow lock: `save_community_v2` is a
3297/// whole-row save that prunes channels absent from the passed struct, so writing
3298/// a stale pre-upload copy would drop rows a concurrent fold just landed.
3299pub async fn persist_community_image(
3300    id: &crate::community::CommunityId,
3301    img: control::ImageRef,
3302    is_banner: bool,
3303    session: &SessionGuard,
3304) -> Option<CommunityV2> {
3305    let lock = super::realtime::follow_lock(id);
3306    let _guard = lock.lock().await;
3307    if !session.is_valid() {
3308        return None;
3309    }
3310    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3311    if is_banner {
3312        fresh.banner = Some(img);
3313    } else {
3314        fresh.icon = Some(img);
3315    }
3316    crate::db::community::save_community_v2(&fresh).ok()?;
3317    Some(fresh)
3318}
3319
3320/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
3321/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
3322pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3323    let session = SessionGuard::capture();
3324    let my_pk = me_pk()?;
3325    ensure_channel_manager(community, &my_pk)?;
3326    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
3327    // mint yet — refuse the flag flip rather than publish an edition no reader can
3328    // key (members would keep posting on the root-derived plane, splitting the
3329    // channel). Private → public works (readers heal to the root derivation).
3330    if meta.private {
3331        if let Some(held) = community.channel(channel_id) {
3332            if !held.private {
3333                return Err("converting a public channel to private is not supported yet".to_string());
3334            }
3335        }
3336    }
3337    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3338    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3339    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await
3340}
3341
3342/// The local mirror of the reader's `MANAGE_CHANNELS` fold gate (CORD-03 §2): the
3343/// owner, or a roster-authorized manager who isn't banned. Refusing BEFORE any
3344/// publish keeps an unauthorized device from advancing its own edition floor onto
3345/// a head every reader rejects (wedging its later, legitimately-authorized edits
3346/// behind a rejected chain).
3347fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3348    let owner = community.owner()?;
3349    if *me == owner {
3350        return Ok(());
3351    }
3352    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3353    let me_hex = me.to_hex();
3354    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3355        return Err("you are banned from this community".to_string());
3356    }
3357    let roster = crate::db::community::get_community_roles(&cid_hex)?;
3358    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3359        Ok(())
3360    } else {
3361        Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3362    }
3363}
3364
3365/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
3366/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
3367/// Plane from the `community_root` (no per-channel key), so other members fold it in on
3368/// their next control follow with nothing to distribute. Returns the new channel id.
3369/// Reader-gated by `MANAGE_CHANNELS`.
3370pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3371    let channel_id = ChannelId(super::super::random_32());
3372    create_public_channel_with_id(transport, community, name, channel_id).await?;
3373    Ok(channel_id)
3374}
3375
3376/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3377/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
3378/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
3379pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3380    let session = SessionGuard::capture();
3381    // Serialize with the follow worker: the save below writes the WHOLE community
3382    // row from this caller's struct, so an unserialized concurrent follow adopting
3383    // a rotation would be rolled back to a stale root (a deaf community).
3384    let lock = super::realtime::follow_lock(community.id());
3385    let _guard = lock.lock().await;
3386    let my_pk = me_pk()?;
3387    ensure_channel_manager(community, &my_pk)?;
3388    assert_channel_id_free(&channel_id, community.id())?;
3389    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3390    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3391    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3392    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3393    if !session.is_valid() {
3394        return Err("account changed during channel create".to_string());
3395    }
3396    // Add locally + persist so the creator can post immediately (peers fold it in).
3397    let mut updated = community.clone();
3398    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() });
3399    crate::db::community::save_community_v2(&updated)?;
3400    Ok(())
3401}
3402
3403/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
3404/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
3405/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
3406/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
3407fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3408    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3409    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3410        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3411        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3412        if existing != mine
3413            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3414        {
3415            return Err("channel id is already live in another v2 community".to_string());
3416        }
3417    }
3418    Ok(())
3419}
3420
3421/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
3422/// random key at channel-epoch 1, deliver the key to every current member over the
3423/// rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`). Epoch 0
3424/// is the root generation ("the first privatisation is epoch 1"), so the delivery
3425/// commits its continuity to `(0, community_root)` — verifiable by every member and
3426/// bound to THIS community's root. The key ships BEFORE the announcement: an
3427/// aborted attempt leaves only an unannounced crate (invisible), and a retry mints
3428/// a fresh id, so there is no same-coordinate double-mint to fork on. Live public
3429/// links are refreshed so a joiner's bundle carries the key; a member who joins
3430/// through the stale-bundle window keys up at the channel's next rotation.
3431pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3432    let channel_id = ChannelId(super::super::random_32());
3433    create_private_channel_with_id(transport, community, name, channel_id).await?;
3434    Ok(channel_id)
3435}
3436
3437/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3438/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
3439pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3440    let session = SessionGuard::capture();
3441    // Serialize with the follow worker across the whole fetch→publish→save span
3442    // (the memberlist fetch is seconds long; an unserialized follow adopting a
3443    // rotation meanwhile would be rolled back by the whole-row save below).
3444    let lock = super::realtime::follow_lock(community.id());
3445    let _guard = lock.lock().await;
3446    let signer = crate::signer::active_signer()?;
3447    let my_pk = me_pk()?;
3448    ensure_channel_manager(community, &my_pk)?;
3449    assert_channel_id_free(&channel_id, community.id())?;
3450    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3451    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3452    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3453
3454    let channel_key = super::super::random_32();
3455    let epoch = Epoch(1);
3456
3457    // Recipients: every current member, plus me (multi-device).
3458    let mut recipients = memberlist(transport, community).await?;
3459    if !recipients.iter().any(|p| *p == my_pk) {
3460        recipients.push(my_pk);
3461    }
3462    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3463    let mut blobs = Vec::with_capacity(recipients.len());
3464    for r in &recipients {
3465        blobs.push(
3466            rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3467                .await
3468                .map_err(|e| e.to_string())?,
3469        );
3470    }
3471    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3472    let at_secs = now_ms() / 1000;
3473    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())
3474        .await
3475        .map_err(|e| e.to_string())?;
3476    if !session.is_valid() {
3477        return Err("account changed during channel create".to_string());
3478    }
3479    for c in &chunks {
3480        transport.publish_durable(c, &community.relays).await?;
3481    }
3482    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3483    if !session.is_valid() {
3484        return Err("account changed during channel create".to_string());
3485    }
3486    // A leave/delete raced the create: saving would resurrect the community row.
3487    if crate::db::community::community_protocol(community.id())?.is_none() {
3488        return Err("community removed during channel create".to_string());
3489    }
3490    let mut updated = community.clone();
3491    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() });
3492    crate::db::community::save_community_v2(&updated)?;
3493    // Archive the epoch-1 key so this channel's history stays readable across its
3494    // future rotations (CORD-03 §3).
3495    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3496    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3497    // Future joiners are handed the key in their (refreshed) bundle, CORD-05 §1.
3498    let _ = refresh_public_links(transport, &updated).await;
3499    Ok(())
3500}
3501
3502/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
3503/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
3504pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
3505    let session = SessionGuard::capture();
3506    // Whole-row save below — serialize with the follow worker (see create_*_channel).
3507    let lock = super::realtime::follow_lock(community.id());
3508    let _guard = lock.lock().await;
3509    let my_pk = me_pk()?;
3510    ensure_channel_manager(community, &my_pk)?;
3511    // The tombstone carries the FULL held document (deleted flag set): a strict
3512    // reader treats an edition as the entity, so even a deletion must not strip
3513    // fields it didn't touch (CORD-02 §6).
3514    let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
3515        name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
3516    });
3517    meta.deleted = Some(true);
3518    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3519    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3520    if !session.is_valid() {
3521        return Err("account changed during channel delete".to_string());
3522    }
3523    let mut updated = community.clone();
3524    updated.channels.retain(|c| c.id.0 != channel_id.0);
3525    crate::db::community::save_community_v2(&updated)?;
3526    Ok(())
3527}
3528
3529// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
3530
3531/// Re-fold this community's Control Plane and apply the current metadata +
3532/// **public** channel set to the held community, persisting any change. Called
3533/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
3534/// edited description) so a long-running bot tracks the community mid-session
3535/// instead of freezing at its join-time view.
3536///
3537/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
3538/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
3539/// edition is eligible only if its signer CURRENTLY holds the entity's management
3540/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
3541/// a demoted one's drop. The owner is supreme, proven by the self-certifying
3542/// community_id (no network trust).
3543///
3544/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
3545/// delivered over the rekey plane (or an invite bundle), never derivable from a
3546/// control edition alone. A new Private channel therefore surfaces only once
3547/// [`follow_rekeys`] delivers its key. Public channels derive from the
3548/// community_root, so they fold in directly.
3549///
3550/// Returns the updated community iff something changed (so the caller can skip a
3551/// redundant re-subscribe + refresh notification).
3552pub async fn follow_control<T: Transport + ?Sized>(
3553    transport: &T,
3554    community: &CommunityV2,
3555    session: &SessionGuard,
3556) -> Result<Option<CommunityV2>, String> {
3557    community.owner()?; // fail fast if the community is somehow unproven.
3558    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3559    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3560
3561    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
3562    // under a prior epoch is excluded, so that entity auto-bootstraps after a
3563    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
3564    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
3565    // window the floor exists to shut.
3566    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
3567        .into_iter()
3568        .filter(|(_, f)| f.0 == community.root_epoch.0)
3569        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
3570        .collect();
3571
3572    // Newest window first; page OLDER only while a tracking entity is gapped (its
3573    // floor link evicted from the window — H1/M8 refetch), bounded like the join
3574    // verifier. A withholding relay still converges to fail-closed after the cap.
3575    let mut editions: Vec<ParsedEdition> = Vec::new();
3576    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
3577    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
3578    let mut oldest: Option<u64> = None;
3579    let mut until: Option<u64> = None;
3580    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
3581    let mut authority = AuthoritySet::owner_only();
3582    // Whether this round gave up with editions still unread. The follow is
3583    // procedural by design — process what arrives, converge with everyone else —
3584    // so a short read never blocks reading, writing or epoch adoption. It only
3585    // withholds the ROSTER cache below: caching a partial authority as this
3586    // device's baseline is the one step that outlives the round.
3587    let mut truncated = true;
3588    for _ in 0..FOLLOW_MAX_PAGES {
3589        // Quorum, DECLARED (the until→Full transport floor is gone): these
3590        // control reads tolerate a partial union — their fold semantics are
3591        // fail-safe on gaps (seeded banlists, withheld roster cache).
3592        let query = Query {
3593            kinds: vec![stream::KIND_WRAP],
3594            authors: vec![control.pk_hex()],
3595            until,
3596            limit: Some(FOLLOW_PAGE),
3597            evidence: crate::community::transport::Evidence::Quorum,
3598            ..Default::default()
3599        };
3600        let wraps = transport.fetch(&query, &community.relays).await?;
3601        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
3602        // at a page boundary); the wrap-id dedup makes re-served boundary events
3603        // free, and a page with nothing new means the relay is exhausted.
3604        let mut fresh = 0usize;
3605        for w in &wraps {
3606            if !seen_wraps.insert(w.id) {
3607                continue;
3608            }
3609            fresh += 1;
3610            let at = w.created_at.as_secs();
3611            if oldest.is_none_or(|o| at < o) {
3612                oldest = Some(at);
3613            }
3614            // Open + seal-verify every edition; authority is resolved by the roster
3615            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
3616            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
3617                if seen.insert(ed.inner_id) {
3618                    editions.push(ed);
3619                }
3620            }
3621        }
3622        // Roster first (roles/grants/banlist → authorized set), then the authority-
3623        // gated metadata/channel fold over the same edition set.
3624        authority = fold_authority(community, &editions, &floors);
3625        fold = apply_control_fold(community, &editions, &floors, &authority);
3626        if !(fold.gapped || authority.gapped) {
3627            truncated = false; // nothing is gapped: this view is coherent
3628            break;
3629        }
3630        if fresh == 0 {
3631            // A FULL page with nothing new is a same-second wall no `until` steps
3632            // past, so older editions stay unreachable; a short page is the end
3633            // of the plane, and a gap in THAT is the relay withholding, not us
3634            // giving up early.
3635            truncated = wraps.len() >= FOLLOW_PAGE;
3636            break;
3637        }
3638        until = oldest;
3639    }
3640
3641    // The fetches straddled awaits; a swap since the guard was captured must not
3642    // write account A's control state into B.
3643    if !session.is_valid() {
3644        return Err("account changed during control follow".to_string());
3645    }
3646    // A leave/delete raced this follow: writing now would resurrect the community
3647    // row and orphan floor rows past delete_community's wipe.
3648    if crate::db::community::community_protocol(community.id())?.is_none() {
3649        return Ok(None);
3650    }
3651    // Persist advanced floors BEFORE the state save (a failed floor write must not
3652    // let saved state outrun its floor), stamping the epoch this fold ran under —
3653    // not the row's write-time value, which a concurrent re-founding can bump. Both
3654    // the metadata/channel heads and the roster/banlist heads advance their floors;
3655    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
3656    for h in fold.heads.iter().chain(authority.heads.iter()) {
3657        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)?;
3658        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)?;
3659    }
3660    // Persist the authorized banlist content (retained/withholding folds carry None,
3661    // so the stored banlist is left intact — an anti-roster never silently un-bans).
3662    let mut authority_changed = false;
3663    // Ban marks MERGE (never replace): they must outlive both the ban and this window,
3664    // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
3665    // itself was retained — the history is what the suppression reads.
3666    let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
3667    if let Some((banned, version)) = &authority.banlist_persist {
3668        let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3669        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
3670        let mut after = banned.clone();
3671        before.sort();
3672        after.sort();
3673        authority_changed |= before != after;
3674    }
3675    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
3676    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
3677    // fetch path: only an aggregate built from roster editions at least as new as
3678    // the stored one may replace it — a withholding relay serving NO roster
3679    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
3680    // and that must RETAIN the stored roster, never wipe standing.
3681    let newest_roster_at: i64 = editions
3682        .iter()
3683        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
3684        .map(|e| e.created_at as i64)
3685        .max()
3686        .unwrap_or(0);
3687    // Completeness gate: the `gapped` flag only covers entities present in the window.
3688    // A role/grant floored on this device but with ZERO editions fetched (aged out of
3689    // the paging reach) folds absent yet raises no gap — persisting would silently drop
3690    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
3691    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
3692    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3693    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
3694    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
3695        && stored.grants.iter().all(|g| {
3696            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
3697                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
3698                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
3699            })
3700        });
3701    // `truncated` covers the case the other three can't: a COLD device (no floors,
3702    // no stored roster) folding under a plane a member has inflated past the pager.
3703    // `stored_complete` is trivially true with nothing stored, so without this the
3704    // first sync would cache a partial authority as its own baseline.
3705    if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
3706        authority_changed |= stored != authority.roles;
3707        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
3708    }
3709    // Roster/banlist moves are invisible in the returned community (they live in
3710    // their own columns), so callers that key a refresh off `updated` would never
3711    // repaint a promote/demote/ban. Announce from the single fold point — it covers
3712    // realtime, boot catch-up and manual sync alike.
3713    if authority_changed {
3714        crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
3715    }
3716    match fold.updated {
3717        Some(u) => {
3718            crate::db::community::save_community_v2(&u)?;
3719            Ok(Some(u))
3720        }
3721        None => Ok(None),
3722    }
3723}
3724
3725/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
3726/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
3727///
3728/// Nearly free to raise: both follow loops exit the moment the fold stops being
3729/// gapped, so the cap only binds when something is genuinely missing — exactly
3730/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
3731/// under a plane that 100 roles + 400 grants already outgrows before counting
3732/// superseded versions, which accumulate until a compaction retires them.
3733const FOLLOW_MAX_PAGES: usize = 32;
3734const FOLLOW_PAGE: usize = 500;
3735/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
3736/// every Control Event must abort). Far above any real plane, but plane depth is
3737/// attacker-controlled — any member holds the key that mints wraps — so the read
3738/// is bounded and reports coming up short rather than compacting a partial view.
3739const COMPACT_MAX_PAGES: usize = 512;
3740
3741/// A folded control head to persist as the per-entity refuse-downgrade floor.
3742#[derive(Clone)]
3743struct FoldedHead {
3744    entity_hex: String,
3745    version: u64,
3746    self_hash: [u8; 32],
3747    inner_id: [u8; 32],
3748}
3749
3750/// The outcome of a floor-aware control fold: the updated community (if content
3751/// changed), the heads to persist as the new floor (returned even when content is
3752/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
3753/// hit an unresolvable gap — the caller's signal to page older history and re-fold
3754/// (CORD-04 H1/M8's refetch).
3755struct ControlFold {
3756    updated: Option<CommunityV2>,
3757    heads: Vec<FoldedHead>,
3758    gapped: bool,
3759}
3760
3761/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
3762type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
3763
3764/// Fold owner-authored control editions into an updated community using the
3765/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
3766/// [`version::fold`]`(floor, floor_hash)`:
3767///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
3768///     doesn't block the verified prefix — refuse-downgrade holds for everything
3769///     applied — but flags `gapped` so the caller pages for the rest.
3770///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
3771///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
3772///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
3773///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
3774///     client lands on the same head where a hash-strict floor would wedge forever.
3775///     Anything else is withholding → fail closed + `gapped`.
3776///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
3777///     Refounding, since the caller epoch-filters the floor) takes the highest
3778///     signed head (author already owner-filtered).
3779/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
3780/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
3781/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
3782/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
3783/// convergence preserved).
3784fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
3785    use crate::community::roles::Permissions;
3786    use std::collections::BTreeMap;
3787
3788    let owner_hex = community.owner().ok().map(|o| o.to_hex());
3789
3790    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
3791    for e in editions {
3792        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
3793    }
3794
3795    let mut out = community.clone();
3796    let mut changed = false;
3797    let mut heads = Vec::new();
3798    let mut gapped = false;
3799    for ((vsk_code, eid), group) in &groups {
3800        // This fold applies exactly two entities: community metadata (eid ==
3801        // community_id) and channel metadata. A vsk-2 whose eid equals the community
3802        // id is excluded — the floor row keys on the entity alone, so it would share
3803        // (and corrupt) the metadata chain's floor.
3804        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
3805        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
3806        if !is_meta && !is_channel {
3807            continue;
3808        }
3809        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
3810        // entity's management bit are eligible. Pre-filtering before the fold means a
3811        // demoted admin's (possibly higher-version) edition can't be the head; the
3812        // highest AUTHORIZED head wins. The owner is supreme.
3813        let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
3814        let authed: Vec<&ParsedEdition> = group
3815            .iter()
3816            .copied()
3817            .filter(|e| {
3818                let author = e.author.to_hex();
3819                // A banned npub's edits are dropped (CORD-04 §4), even if they still
3820                // held a bit via a not-yet-stripped grant.
3821                !authority.banned.contains(&author)
3822                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
3823                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
3824                    // this same fold settled, so it works on a bootstrap where no
3825                    // persisted head exists yet.
3826                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
3827            })
3828            .collect();
3829        if authed.is_empty() {
3830            continue;
3831        }
3832        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
3833        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
3834        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
3835        gapped |= entity_gapped;
3836        let Some(hi) = hi else { continue };
3837
3838        let head = authed[hi];
3839        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
3840        if is_meta {
3841            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
3842                changed |= apply_community_metadata(&mut out, meta);
3843            }
3844        } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
3845            // vsk-2 carries no community binding (shared v1 grammar); a same-owner
3846            // cross-community replay can inject a phantom PUBLIC channel (bounded:
3847            // root-scoped key, eids don't collide). Binding is a deferred wire change.
3848            changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
3849        }
3850    }
3851    ControlFold { updated: changed.then_some(out), heads, gapped }
3852}
3853
3854/// Fold one entity's editions against its persisted floor into a head index (into the
3855/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
3856/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
3857/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
3858/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
3859/// the lower-inner-id winner; and fail closed otherwise.
3860fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
3861    let floor_v = floor.map(|f| f.0).unwrap_or(0);
3862    if floor_v == 0 {
3863        return (version::bootstrap_head(fold_eds, 0), false);
3864    }
3865    let floor_hash = floor.map(|f| &f.1);
3866    let held_inner = floor.and_then(|f| f.2);
3867    let result = version::fold(fold_eds, floor_v, floor_hash);
3868    if result.anchored {
3869        return (result.head, result.gap); // verified prefix; page any upper gap.
3870    }
3871    if result.head.is_none() && !result.gap {
3872        return (None, false); // everything below floor — a stale relay, no paging.
3873    }
3874    // Unanchored under a held floor: converge a same-version fork at the floor to its
3875    // deterministic winner (lower inner id; a NULL held id is always replaceable),
3876    // else fail closed.
3877    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
3878    let win_hash = match fork {
3879        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
3880        _ => return (None, true), // detached from our committed head → withholding.
3881    };
3882    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
3883    if !re.anchored {
3884        return (None, true);
3885    }
3886    (re.head, re.gap)
3887}
3888
3889/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
3890/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
3891/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
3892/// recomputed from the self-certifying community_id at each use.
3893struct AuthoritySet {
3894    roles: crate::community::roles::CommunityRoles,
3895    banned: std::collections::BTreeSet<String>,
3896    heads: Vec<FoldedHead>,
3897    gapped: bool,
3898    /// The authorized banlist `(content, version)` to persist when an authorized head
3899    /// advanced the floor. `None` when the banlist was retained (no new authorized
3900    /// head) or is empty — the caller then leaves the stored banlist untouched.
3901    banlist_persist: Option<(Vec<String>, u64)>,
3902    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
3903    /// named them, across every edition in the window rather than just the head. Outlives
3904    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
3905    banned_at: std::collections::BTreeMap<String, u64>,
3906}
3907
3908impl AuthoritySet {
3909    /// Bootstrap authority for a community with no roster editions folded yet: only
3910    /// the owner is authorized (supreme), nobody banned.
3911    fn owner_only() -> Self {
3912        AuthoritySet {
3913            roles: Default::default(),
3914            banned: Default::default(),
3915            heads: vec![],
3916            gapped: false,
3917            banlist_persist: None,
3918            banned_at: Default::default(),
3919        }
3920    }
3921}
3922
3923/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
3924/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
3925/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
3926/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
3927/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
3928/// banlist is enforced only if its head's signer held BAN in the authorized roster.
3929fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
3930    use crate::community::roles::Permissions;
3931    use std::collections::BTreeMap;
3932
3933    let cid = community.id();
3934    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3935    let owner = community.owner().ok();
3936    let owner_hex = owner.map(|o| o.to_hex());
3937    let banlist_eid = super::derive::banlist_locator(cid);
3938    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
3939
3940    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
3941    for e in editions {
3942        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
3943            groups.entry(e.entity_id).or_default().push(e);
3944        }
3945    }
3946
3947    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
3948    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
3949    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
3950    // to the next candidate, so a forged higher-version edition can't suppress the
3951    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
3952    // let any member vanish a role or a member's grant). `gapped` (drives older-
3953    // paging) stays fold_head's per-entity flag.
3954    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
3955    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
3956    let mut gapped = false;
3957
3958    for (eid, group) in &groups {
3959        // The banlist is folded author-aware AFTER the roster is known (below).
3960        if *eid == banlist_eid {
3961            continue;
3962        }
3963        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
3964        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
3965        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
3966        gapped |= entity_gapped;
3967        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
3968
3969        for p in group {
3970            // Refuse-downgrade: never consider an edition below the persisted floor.
3971            if p.version < floor_v {
3972                continue;
3973            }
3974            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
3975            match p.vsk.as_str() {
3976                vsk::ROLE => {
3977                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
3978                    if let Some(role) = super::roles::parse_role_content(&p.content) {
3979                        if role.role_id == entity_hex && role.position != 0 {
3980                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
3981                        }
3982                    }
3983                }
3984                vsk::GRANT => {
3985                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
3986                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
3987                            if super::derive::grant_locator(cid, &member) == *eid {
3988                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
3989                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
3990                            }
3991                        }
3992                    }
3993                }
3994                _ => {}
3995            }
3996        }
3997    }
3998    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
3999        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4000    }
4001
4002    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4003    // Preliminary roster (bans not yet applied) — the authority view the banlist head
4004    // is judged against.
4005    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4006
4007    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
4008    // both closed:
4009    //   - head selection: the head is the highest version whose author CURRENTLY holds
4010    //     BAN — an unauthorized higher-version edition can't erase existing bans
4011    //     (fail-open), and the floor never advances to one;
4012    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
4013    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
4014    //     owner is unbannable);
4015    //   - withholding: when no authorized head is served, the persisted banlist is
4016    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
4017    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4018    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
4019    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
4020    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
4021    // head eligibility, not just from the roster.
4022    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4023    let banlist_authored: Vec<&ParsedEdition> = groups
4024        .get(&banlist_eid)
4025        .map(|g| {
4026            g.iter()
4027                .copied()
4028                .filter(|e| {
4029                    let ah = e.author.to_hex();
4030                    !banned_authors.contains(ah.as_str())
4031                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4032                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4033                })
4034                .collect()
4035        })
4036        .unwrap_or_default();
4037    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
4038    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
4039    // head alone forgets the ban that the suppression exists to remember. The owner is
4040    // skipped: they are never bannable, and a moderator listing them must not durably
4041    // suppress them past the un-ban.
4042    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4043    for p in &banlist_authored {
4044        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4045            if owner_hex.as_deref() == Some(t.as_str()) {
4046                continue;
4047            }
4048            let slot = banned_at.entry(t).or_insert(0);
4049            *slot = (*slot).max(p.created_at);
4050        }
4051    }
4052    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4053    let mut banlist_head: Option<FoldedHead> = None;
4054    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4055        persisted_banned.into_iter().collect()
4056    } else {
4057        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4058        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4059        gapped |= g;
4060        match hi {
4061            Some(hi) => {
4062                let head = banlist_authored[hi];
4063                let ah = head.author.to_hex();
4064                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4065                    .unwrap_or_default()
4066                    .into_iter()
4067                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4068                    .take(super::roles::MAX_BANLIST)
4069                    .collect();
4070                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4071                banlist_persist = Some((list.clone(), head.version));
4072                list.into_iter().collect()
4073            }
4074            None => persisted_banned.into_iter().collect(),
4075        }
4076    };
4077
4078    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
4079    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
4080    // the banned set excluded so a banned admin loses authority.
4081    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
4082    if let Some(bh) = banlist_head {
4083        heads.push(bh);
4084    }
4085
4086    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
4087    // authorization, so an attacker's unauthorized roles can't consume cap slots and
4088    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
4089    // roles empty the roster).
4090    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
4091        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
4092        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
4093        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
4094        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
4095        authorized.grants.retain(|g| !g.role_ids.is_empty());
4096    }
4097
4098    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
4099}
4100
4101/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
4102/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
4103struct AuthorityCand {
4104    role: Option<crate::community::roles::Role>,
4105    grant: Option<crate::community::roles::MemberGrant>,
4106    author: PublicKey,
4107    head: FoldedHead,
4108    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
4109    /// (supreme, cites nothing) or an uncited one — the latter is refused.
4110    citation: Option<crate::community::edition::AuthorityCitation>,
4111}
4112
4113/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
4114///
4115/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
4116/// pass that establishes those heads, so an external floor would refuse every
4117/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
4118/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
4119/// authority-verified head as its baseline, a tracking client fails closed per
4120/// entity — applied to the citation instead of the chain link.
4121fn citation_ok_in_fold(
4122    cid: &crate::community::CommunityId,
4123    heads: &[FoldedHead],
4124    owner_hex: Option<&str>,
4125    author: &PublicKey,
4126    citation: Option<&crate::community::edition::AuthorityCitation>,
4127) -> bool {
4128    let actor_hex = author.to_hex();
4129    if owner_hex == Some(actor_hex.as_str()) {
4130        return true;
4131    }
4132    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
4133    let as_entity: Vec<crate::community::roster::EntityHead> = heads
4134        .iter()
4135        .map(|h| crate::community::roster::EntityHead {
4136            entity_hex: h.entity_hex.clone(),
4137            version: h.version,
4138            self_hash: h.self_hash,
4139            inner_id: h.inner_id,
4140            citation: None,
4141        })
4142        .collect();
4143    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
4144}
4145
4146/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
4147/// takes the highest-version candidate whose author is authorized to author it under
4148/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
4149/// vanishing the entity. Authority resolves outward from the owner (proven by
4150/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
4151/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
4152/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
4153/// advances only to authorized heads — an unauthorized forgery never poisons it).
4154fn select_authorized(
4155    cid: &crate::community::CommunityId,
4156    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4157    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4158    owner_hex: Option<&str>,
4159    excluded: &std::collections::BTreeSet<String>,
4160) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
4161    use crate::community::roles::{CommunityRoles, Permissions};
4162    let mut accepted = CommunityRoles::default();
4163    let mut heads: Vec<FoldedHead> = Vec::new();
4164    // Jacobi iteration: authority propagates one delegation level per round, so a
4165    // generous multiple of the entity count is an ample bound. Non-convergence (never
4166    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
4167    // are ever selected.
4168    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
4169    for _ in 0..bound {
4170        let mut next = CommunityRoles::default();
4171        let mut next_heads: Vec<FoldedHead> = Vec::new();
4172
4173        for cands in role_cands.values() {
4174            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
4175            // is necessary but not sufficient: an edition REPLACES the entity, so
4176            // the author must also outrank the position standing before it.
4177            // Without that, an admin at position 5 rewrites the position-1 role
4178            // to position 9 — every check passes, since 9 is beneath them — and
4179            // a role that outranked them is now beneath them, along with everyone
4180            // holding it. Rank inversion by republish.
4181            //
4182            // The chain is replayed ASCENDING so each version is judged against
4183            // the position its own predecessor established, then the highest
4184            // admissible version wins (candidates arrive version-DESC, forks
4185            // broken by lowest inner_id — preserved by walking version groups).
4186            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4187            let mut standing: Option<u32> = None;
4188            let mut i = cands.len();
4189            while i > 0 {
4190                let hi = i;
4191                let ver = cands[i - 1].head.version;
4192                while i > 0 && cands[i - 1].head.version == ver {
4193                    i -= 1;
4194                }
4195                // One winner per version: fork siblings can't sidestep the gate.
4196                for c in cands[i..hi].iter().rev() {
4197                    let Some(role) = &c.role else { continue };
4198                    let ah = c.author.to_hex();
4199                    if excluded.contains(&ah) || role.position == 0 {
4200                        continue;
4201                    }
4202                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
4203                        continue;
4204                    }
4205                    if let Some(prev) = standing {
4206                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
4207                            continue;
4208                        }
4209                    }
4210                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4211                        continue;
4212                    }
4213                    admissible.insert(c.head.self_hash);
4214                    standing = Some(role.position);
4215                    break;
4216                }
4217            }
4218            for c in cands {
4219                let Some(role) = &c.role else { continue };
4220                if !admissible.contains(&c.head.self_hash) {
4221                    continue;
4222                }
4223                next.roles.push(role.clone());
4224                next_heads.push(c.head.clone());
4225                break; // highest admissible candidate for this entity
4226            }
4227        }
4228        for cands in grant_cands.values() {
4229            for c in cands {
4230                let Some(grant) = &c.grant else { continue };
4231                let ah = c.author.to_hex();
4232                if excluded.contains(&ah) || excluded.contains(&grant.member) {
4233                    continue;
4234                }
4235                // The granter must outrank every granted role (resolved against the
4236                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
4237                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
4238                let Some(positions) = positions else { continue };
4239                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4240                    continue;
4241                }
4242                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
4243                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
4244                {
4245                    // Record the head even for an EMPTY grant (a revoke is a real chain
4246                    // advance a completeness check must see), but don't carry the husk
4247                    // into the roster.
4248                    next_heads.push(c.head.clone());
4249                    if !grant.role_ids.is_empty() {
4250                        next.grants.push(grant.clone());
4251                    }
4252                    break;
4253                }
4254            }
4255        }
4256
4257        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
4258        accepted = next;
4259        heads = next_heads;
4260        if converged {
4261            break;
4262        }
4263    }
4264    (accepted, heads)
4265}
4266
4267/// Apply a folded community-metadata head. Relays only overwrite when the edition
4268/// carries a non-empty list (a metadata edition that omits relays must not blank
4269/// the working set). Returns whether anything changed.
4270fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
4271    let mut changed = false;
4272    if out.name != meta.name {
4273        out.name = meta.name;
4274        changed = true;
4275    }
4276    if out.description != meta.description {
4277        out.description = meta.description;
4278        changed = true;
4279    }
4280    // Icon/banner apply verbatim, None included — an edition is the full
4281    // document, so an absent image IS a removal (editors preserve via
4282    // `CommunityV2::metadata()`).
4283    if out.icon != meta.icon {
4284        out.icon = meta.icon;
4285        changed = true;
4286    }
4287    if out.banner != meta.banner {
4288        out.banner = meta.banner;
4289        changed = true;
4290    }
4291    // Client-extensible + unknown fields ride the fold verbatim so our own
4292    // editions can carry them forward (CORD-02 §6).
4293    if out.meta_custom != meta.custom {
4294        out.meta_custom = meta.custom;
4295        changed = true;
4296    }
4297    if out.meta_extra != meta.extra {
4298        out.meta_extra = meta.extra;
4299        changed = true;
4300    }
4301    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
4302    // other construction boundary, and the fold is a boundary like any other: an
4303    // authorized editor is not a trusted one, and an oversize list costs every
4304    // member a fan-out on each publish and the slowest of N on each fetch
4305    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
4306    // CAPPED list too — against the raw one, an oversize edition never compares
4307    // equal, so every fold would report a change and re-save forever.
4308    let relays = crate::community::cap_relays(meta.relays);
4309    if !relays.is_empty() && out.relays != relays {
4310        out.relays = relays;
4311        changed = true;
4312    }
4313    changed
4314}
4315
4316/// Apply a folded channel-metadata head: delete removes the channel, a rename
4317/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
4318/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
4319/// rekey plane or a fresh bundle). Returns whether anything changed.
4320fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
4321    let deleted = meta.deleted.unwrap_or(false);
4322    if deleted {
4323        let before = out.channels.len();
4324        out.channels.retain(|c| c.id.0 != id.0);
4325        return out.channels.len() != before;
4326    }
4327    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
4328        Some(existing) => {
4329            let mut changed = false;
4330            if existing.name != meta.name {
4331                existing.name = meta.name;
4332                changed = true;
4333            }
4334            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
4335            // local edit republishes them instead of wiping (CORD-02 §6).
4336            if existing.voice != meta.voice {
4337                existing.voice = meta.voice;
4338                changed = true;
4339            }
4340            if existing.meta_custom != meta.custom {
4341                existing.meta_custom = meta.custom;
4342                changed = true;
4343            }
4344            if existing.meta_extra != meta.extra {
4345                existing.meta_extra = meta.extra;
4346                changed = true;
4347            }
4348            // The owner's edition authoritatively declares visibility. A channel the
4349            // owner marks PUBLIC must derive from the root (key = None) — this heals a
4350            // bundle-time misclassification where an attacker set a public channel's
4351            // grant key to their own, silently addressing it at a plane only they read.
4352            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
4353            // record stays public) until the convert flow (key mint + cursor rebase
4354            // to the conversion's channel epoch) lands — the send side refuses to
4355            // publish one, and a foreign client's conversion won't move us.
4356            if !meta.private && (existing.private || existing.key.is_some()) {
4357                existing.private = false;
4358                existing.key = None;
4359                changed = true;
4360            }
4361            changed
4362        }
4363        None if !meta.private => {
4364            // A public channel derives its Chat Plane from the community_root at the
4365            // current root epoch (key = None); its stored epoch mirrors the root.
4366            out.channels.push(ChannelV2 {
4367                id,
4368                name: meta.name,
4369                private: false,
4370                key: None,
4371                epoch: out.root_epoch,
4372                voice: meta.voice,
4373                meta_custom: meta.custom,
4374                meta_extra: meta.extra,
4375            });
4376            true
4377        }
4378        None => {
4379            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
4380            // generation — CORD-03 §2 numbers the first private key epoch 1). The
4381            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
4382            // delivers a key, every read/send/subscribe path skips the channel; the
4383            // root-fallback in `channel_secret` is never taken for it.
4384            out.channels.push(ChannelV2 {
4385                id,
4386                name: meta.name,
4387                private: true,
4388                key: None,
4389                epoch: Epoch(0),
4390                voice: meta.voice,
4391                meta_custom: meta.custom,
4392                meta_extra: meta.extra,
4393            });
4394            true
4395        }
4396    }
4397}
4398
4399// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
4400
4401/// The outcome of a rekey-follow pass.
4402pub struct RekeyFollow {
4403    /// The community after adopting every rotation it could catch up on, or `None`
4404    /// if nothing advanced.
4405    pub updated: Option<CommunityV2>,
4406    /// A base rotation removed us — the caller tears the local hold down (the
4407    /// updated community is not persisted in that case).
4408    pub self_removed: bool,
4409    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
4410    /// flag is already set; the caller surfaces the death and stops following.
4411    pub dissolved: bool,
4412}
4413
4414/// The most archived base roots a channel-rekey lookup fans across per step. A
4415/// standalone rekey rides the minter's then-current root and a removal's rides the
4416/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
4417/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
4418/// an older root than the fan reaches) only heals via a fresh invite bundle — the
4419/// walk is strictly sequential, so a later rotation can't be reached either.
4420const MAX_ADDRESSING_ROOTS: usize = 8;
4421
4422/// The base roots a channel rekey may be addressed under, freshest first: the
4423/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
4424/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
4425/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
4426/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
4427/// fetch addresses but auth never registered is invisible on an AUTH-gating
4428/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
4429/// wedges at its old epoch while the base advances.
4430pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
4431    let mut roots: Vec<[u8; 32]> = vec![cur_root];
4432    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
4433        .unwrap_or_default();
4434    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
4435    for (_, r) in archived {
4436        if !roots.contains(&r) {
4437            roots.push(r);
4438        }
4439    }
4440    roots.truncate(MAX_ADDRESSING_ROOTS);
4441    roots
4442}
4443
4444/// Follow rekeys for a held community: advance the base (root) epoch and each
4445/// Private channel's epoch as far as authorized rotations allow, adopting the
4446/// fresh key we're still a recipient of at each step and dropping a scope we've
4447/// been removed from. Persists the result. Called when a rekey wrap arrives in
4448/// realtime so a long-running bot keeps decrypting after a rotation instead of
4449/// going silent.
4450///
4451/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
4452/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
4453/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
4454/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
4455/// roster (folded + persisted by `follow_control`), minus the banlist — so an
4456/// admin-created private channel keys up on every member.
4457///
4458/// **Addressing fans across held base roots:** each channel step queries its
4459/// next-epoch rekey address under the current root AND the archived prior roots,
4460/// so a base adopt landing before a Refounding's prior-root-addressed channel
4461/// rekeys (or before a creation delivery minted under an older root) can't
4462/// strand the channel.
4463///
4464/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
4465/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
4466/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
4467/// every follower converges. An incomplete rotation (a missing chunk) never
4468/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
4469/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
4470/// §2: "a convergence check, not a secrecy mechanism") — authority is its
4471/// boundary; its epoch is the scan cursor, advancing past complete rotations
4472/// that exclude us so the walk converges on the channel's current epoch.
4473/// Diagnostic: run the base-rotation fetch+parse pipeline for a wedged community
4474/// and report, per rotation found at the next-epoch base plane, WHY
4475/// `follow_rekeys` did or didn't adopt it — the exact `advance_scope` gate that
4476/// tripped. Read-only. Every rotator/owner is a PUBLIC key; no secret material
4477/// is returned.
4478#[cfg(debug_assertions)]
4479pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
4480    transport: &T,
4481    community: &CommunityV2,
4482) -> Result<serde_json::Value, String> {
4483    let my_xonly = me_pk()?.to_bytes();
4484    let owner = community.owner()?;
4485    let owner_hex = owner.to_hex();
4486    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4487    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4488    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4489    let held_epoch = community.root_epoch;
4490    let held_key = community.community_root;
4491    let next = Epoch(held_epoch.0.saturating_add(1));
4492    let group = base_rekey_group_key(&held_key, community.id(), next);
4493    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
4494    let rotations = rekey::collect_rotations(&chunks);
4495
4496    let reports: Vec<serde_json::Value> = rotations
4497        .iter()
4498        .map(|r| {
4499            let rotator_is_owner = r.rotator == owner;
4500            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
4501            // Roster, not owner-identity — report that gate, not just owner-equality.
4502            let rotator_authorized = rotator_is_owner
4503                || (!banned.contains(&r.rotator.to_hex())
4504                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
4505            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
4506            let epoch_ok = r.new_epoch.0 == next.0;
4507            let complete = r.is_complete();
4508            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
4509            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
4510            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
4511            // is a takeover attempt — this tells whether an "owner must be kept"
4512            // adopt-block would be safe here (it would falsely reject a legitimate
4513            // rotation that happened to exclude the owner).
4514            let owner_kept = r.rotator == owner
4515                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
4516            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
4517            let verdict = if !rotator_authorized {
4518                "REJECTED: rotator holds no BAN authority in the folded roster"
4519            } else if !scope_ok {
4520                "REJECTED: scope is not Root"
4521            } else if !epoch_ok {
4522                "REJECTED: new_epoch != held+1"
4523            } else if !complete {
4524                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
4525            } else if continuity != "Extends" {
4526                "REJECTED: continuity does not extend my held root (FORK/GAP)"
4527            } else if has_my_blob {
4528                "ADOPT: authorized + complete + continuous + my blob present"
4529            } else {
4530                "REMOVED: complete authorized rotation with no blob for me"
4531            };
4532            serde_json::json!({
4533                "rotator": r.rotator.to_hex(),
4534                "rotator_is_recorded_owner": rotator_is_owner,
4535                "rotator_authorized_ban": rotator_authorized,
4536                "scope_is_root": scope_ok,
4537                "new_epoch": r.new_epoch.0,
4538                "prev_epoch": r.prev_epoch.0,
4539                "declared_chunks": r.declared_chunks,
4540                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
4541                "is_complete": complete,
4542                "continuity_vs_held_root": continuity,
4543                "my_blob_present": has_my_blob,
4544                "owner_kept": owner_kept,
4545                "blob_count": r.blobs.len(),
4546                "verdict": verdict,
4547            })
4548        })
4549        .collect();
4550
4551    Ok(serde_json::json!({
4552        "recorded_owner": owner.to_hex(),
4553        "held_root_epoch": held_epoch.0,
4554        "probing_next_epoch": next.0,
4555        "base_plane_pk": group.pk_hex(),
4556        "raw_chunks_parsed": chunks.len(),
4557        "rotations_found": rotations.len(),
4558        "rotations": reports,
4559    }))
4560}
4561
4562pub async fn follow_rekeys<T: Transport + ?Sized>(
4563    transport: &T,
4564    community: &CommunityV2,
4565    session: &SessionGuard,
4566) -> Result<RekeyFollow, String> {
4567    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
4568    // past its tombstone — don't adopt a rotation into a grave.
4569    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4570    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
4571        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
4572    }
4573    // An offline member must also LEARN of a death: the tombstone rides its own
4574    // public plane, which the live sub watches but no catch-up fetch touched —
4575    // without this, a member who slept through a dissolution follows (and posts
4576    // into) a grave forever. Fail-open on transport failure: availability is
4577    // never death.
4578    if is_dissolved(transport, community).await {
4579        if session.is_valid() {
4580            let _ = crate::db::community::set_community_dissolved(&cid_hex);
4581        }
4582        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
4583    }
4584    let signer = crate::signer::active_signer()?;
4585    let my_pk = me_pk()?;
4586    let my_xonly = my_pk.to_bytes();
4587    let owner = community.owner()?;
4588    let owner_hex = owner.to_hex();
4589    let mut cur = community.clone();
4590    let mut changed = false;
4591
4592    // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
4593    // follow_control; the worker folds control right after this rekey pass). This
4594    // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
4595    // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
4596    // the base-admissibility protected-set: a superior whose grant this receiver
4597    // has not yet folded is not in `roster.grants`, so a non-owner Refounding
4598    // excluding them can be adopted within that propagation window. Bounded — the
4599    // owner is ALWAYS hard-protected below (independent of the roster) and can
4600    // counter-refound; and it is inherent to eventual consistency (one cannot gate
4601    // on a grant never seen). Tightening this (fold control before the first rekey,
4602    // or gate non-owner adoption on roster freshness) is a follow-on.
4603    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4604    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4605    let me_hex = my_pk.to_hex();
4606    // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
4607    // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
4608    // never honored by a lagging client." Persisted heads ARE the right floor
4609    // here (unlike the roster fold, which must resolve in-pass): a rotation is
4610    // judged against a roster we already folded, and `follow_control` — v2's only
4611    // roster writer — persists the heads in the same pass it writes the roster.
4612    // A joiner who sees a rotation before folding control simply parks it and
4613    // heals on the next follow, which runs control first.
4614    let cited_ok = |rot: &rekey::Rotation| -> bool {
4615        citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
4616    };
4617    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
4618        if *rotator == owner {
4619            return true;
4620        }
4621        let rh = rotator.to_hex();
4622        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
4623    };
4624    // Concluding MY removal takes more than the bit: the rotator must strictly
4625    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
4626    // removed target"), so an equal-rank admin can never silently evict a peer
4627    // (or the owner) by minting a complete rotation that skips their blob.
4628    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
4629        if *rotator == owner {
4630            return true;
4631        }
4632        let rh = rotator.to_hex();
4633        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
4634    };
4635    // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
4636    // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
4637    // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
4638    // owner is always authorized. (Owner-only here silently wedged every member
4639    // whose community was refounded by a non-owner admin.)
4640    let base_rotator_ok = |rotator: &PublicKey| -> bool {
4641        if *rotator == owner {
4642            return true;
4643        }
4644        let rh = rotator.to_hex();
4645        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
4646    };
4647    // Concluding MY removal via a base rotation takes more than the bit: the
4648    // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
4649    // Rotator must strictly outrank every removed target"), so an equal-rank
4650    // admin can never evict a peer (or the owner) by minting a rotation that
4651    // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
4652    let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
4653        if *rotator == owner {
4654            return true;
4655        }
4656        let rh = rotator.to_hex();
4657        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
4658    };
4659
4660    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
4661    // finite chain terminates naturally; the cap defends against a relay feeding a
4662    // pathological set.
4663    const MAX_STEPS: usize = 128;
4664    for _ in 0..MAX_STEPS {
4665        let mut advanced = false;
4666
4667        // The roots a channel rekey may be addressed under (re-read each pass —
4668        // a base adopt below changes the head, and its predecessor is already
4669        // archived). Shared with streamauth so the auth registration covers
4670        // exactly this fan.
4671        let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
4672
4673        // Private channels first: a removal-forced channel rekey rides the PRIOR
4674        // root (CORD-06 D2), so read channels before a base adopt moves it.
4675        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
4676        for cid in channel_ids {
4677            let (held_key, held_epoch) = match cur.channel(&cid) {
4678                Some(ch) => (ch.key, ch.epoch),
4679                None => continue,
4680            };
4681            let next = Epoch(held_epoch.0.saturating_add(1));
4682            let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4683            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
4684            // root #0 = current, #1.. = archived priors (indices only — root
4685            // bytes are key material and must never reach a log).
4686            for (ri, root) in addressing_roots.iter().enumerate() {
4687                let group = channel_rekey_group_key(root, &cid, next);
4688                let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
4689                    Ok(c) => c,
4690                    Err(e) => {
4691                        crate::log_warn!(
4692                            "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
4693                            &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
4694                        );
4695                        return Err(e);
4696                    }
4697                };
4698                if chunks.is_empty() {
4699                    continue;
4700                }
4701                crate::log_debug!(
4702                    "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
4703                    &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
4704                );
4705                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
4706            }
4707            // Keyless-adopt residual (documented, deferred hardening): a malicious
4708            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
4709            // rotation nothing extends (keyed members' continuity filters it out).
4710            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
4711            // exclude the member outright anyway, so the marginal harm is the wedge
4712            // outliving their demotion.
4713            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
4714                Advance::Adopt { new_key } => {
4715                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
4716                        ch.key = Some(new_key);
4717                        ch.epoch = next;
4718                    }
4719                    crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
4720                    // The adopter's own multi-epoch archive (the minter archived at
4721                    // mint) — this channel's history stays readable across rotations.
4722                    // fetch_channel compensates for the CURRENT epoch, so a failed
4723                    // archive only bites after the NEXT rotation — surface it.
4724                    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) {
4725                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
4726                    }
4727                    advanced = true;
4728                    changed = true;
4729                }
4730                Advance::Removed => {
4731                    match held_key {
4732                        // A complete rotation dropped my blob — cut from the channel.
4733                        Some(_) => {
4734                            cur.channels.retain(|c| c.id.0 != cid.0);
4735                        }
4736                        // Keyless scan: this epoch's rotation completed without me.
4737                        // Advance the cursor so the walk converges on the channel's
4738                        // CURRENT epoch — my entry point is its next rotation (whose
4739                        // recipients are the members at that time) or a fresh bundle.
4740                        None => {
4741                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
4742                                ch.epoch = next;
4743                            }
4744                        }
4745                    }
4746                    advanced = true;
4747                    changed = true;
4748                }
4749                Advance::Stay => {}
4750            }
4751        }
4752
4753        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
4754        // every public channel, the guestbook, and the control plane by derivation
4755        // (refresh_subscription recomputes the author-set from the new root).
4756        {
4757            let held_epoch = cur.root_epoch;
4758            let held_key = cur.community_root;
4759            let next = Epoch(held_epoch.0.saturating_add(1));
4760            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
4761            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
4762            let batches = vec![(chunks, Some((held_epoch, held_key)))];
4763            // A non-owner Refounding may only remove members the rotator strictly
4764            // OUTRANKS. The protected set is the owner plus every grant-holder the
4765            // rotator can't act on with BAN (a peer or superior) — excluding one is
4766            // an authority-escalation takeover, so its rotation is inadmissible.
4767            // Plain members hold no grant and are always outranked by a BAN-holder,
4768            // so removing them is legitimate and needs no memberlist.
4769            let base_admissible = |r: &rekey::Rotation| -> bool {
4770                if r.rotator == owner {
4771                    return true; // the owner is supreme.
4772                }
4773                // Uncited (or citing a Grant we haven't synced) → skip entirely:
4774                // neither adopt nor conclude a removal, exactly like an
4775                // unauthorized rotation. It parks and heals on the next follow.
4776                if !cited_ok(r) {
4777                    return false;
4778                }
4779                let rotator_hex = r.rotator.to_hex();
4780                let has_blob = |xonly: &[u8; 32]| {
4781                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
4782                };
4783                // The owner is never a valid removed target.
4784                if !has_blob(&owner.to_bytes()) {
4785                    return false;
4786                }
4787                for g in &roster.grants {
4788                    if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
4789                        continue; // self, owner (checked), or an already-authorized removal.
4790                    }
4791                    // A grant-holder the rotator can't act on is a peer/superior.
4792                    if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
4793                        if let Ok(pk) = PublicKey::from_hex(&g.member) {
4794                            if !has_blob(&pk.to_bytes()) {
4795                                return false; // a peer/superior was excluded.
4796                            }
4797                        }
4798                    }
4799                }
4800                true
4801            };
4802            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
4803                Advance::Adopt { new_key } => {
4804                    cur.community_root = new_key;
4805                    cur.root_epoch = next;
4806                    // Archive on adopt: without this, a member who lived through TWO
4807                    // Refoundings loses the middle epoch's public history (only the
4808                    // minter archived it).
4809                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
4810                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
4811                    }
4812                    advanced = true;
4813                    changed = true;
4814                }
4815                Advance::Removed => {
4816                    if !session.is_valid() {
4817                        return Err("account changed during rekey follow".to_string());
4818                    }
4819                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
4820                }
4821                Advance::Stay => {}
4822            }
4823        }
4824
4825        if !advanced {
4826            break;
4827        }
4828    }
4829
4830    if !changed {
4831        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
4832    }
4833    if !session.is_valid() {
4834        return Err("account changed during rekey follow".to_string());
4835    }
4836    // A leave/delete raced this follow: saving would resurrect the community row
4837    // (the save is an upsert) with no floor rows behind it.
4838    if crate::db::community::community_protocol(community.id())?.is_none() {
4839        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
4840    }
4841    crate::db::community::save_community_v2(&cur)?;
4842    // Carry my own live links across the rotation someone ELSE performed
4843    // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
4844    // their own — so without this every other creator's links keep vending the
4845    // superseded root and drop new joiners onto a dead epoch, which is exactly
4846    // the stranding the stable-URL refresh exists to prevent. Best-effort and
4847    // idempotent: a creator with no links for this community returns early, and
4848    // a failure only delays the heal until the next adoption or refound.
4849    let _ = refresh_public_links(transport, &cur).await;
4850    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
4851}
4852
4853/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
4854/// address.
4855enum Advance {
4856    /// Adopt this fresh key for `next_epoch`.
4857    Adopt { new_key: [u8; 32] },
4858    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
4859    Removed,
4860    /// No owner rotation extends my held epoch (yet) — keep the current key.
4861    Stay,
4862}
4863
4864/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
4865async fn fetch_rekey_chunks<T: Transport + ?Sized>(
4866    transport: &T,
4867    relays: &[String],
4868    group: &GroupKey,
4869) -> Result<Vec<rekey::RekeyChunk>, String> {
4870    // A rekey plane address is community_root-derived, so ANY member can seal junk
4871    // 3303s there — a flood (or, organically, a large community's own multi-chunk
4872    // rotation past the newest window) could bury the genuine owner/admin rotation
4873    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
4874    // control pager's discipline) so a buried authorized chunk is still recovered;
4875    // the seal + authority filter downstream drops the junk. Bounded — a sustained
4876    // flood past this depth degrades to "adopt one pass late", never a false state.
4877    const REKEY_PAGE: usize = 200;
4878    const REKEY_MAX_PAGES: usize = 6;
4879    let mut out = Vec::new();
4880    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4881    let mut until: Option<u64> = None;
4882    let mut oldest: Option<u64> = None;
4883    for _ in 0..REKEY_MAX_PAGES {
4884        let query = Query {
4885            kinds: vec![stream::KIND_WRAP],
4886            authors: vec![group.pk_hex()],
4887            until,
4888            limit: Some(REKEY_PAGE),
4889            ..Default::default()
4890        };
4891        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
4892        // shared user-authed client's REQ for a plane's events is CLOSED, so an
4893        // offline rotation catch-up would return nothing and wedge at the old
4894        // epoch. `fetch_plane` rides a connection authed as the plane itself.
4895        let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
4896        let mut fresh = 0usize;
4897        for w in &wraps {
4898            if !seen.insert(w.id) {
4899                continue;
4900            }
4901            fresh += 1;
4902            let at = w.created_at.as_secs();
4903            if oldest.is_none_or(|o| at < o) {
4904                oldest = Some(at);
4905            }
4906            if let Ok(opened) = stream::open_wrap(w, group) {
4907                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
4908                    out.push(chunk);
4909                }
4910            }
4911        }
4912        // Drained, or a same-second wall the pager can't step past (second-granular
4913        // until) — either way stop; the accumulated set is what advance_scope folds.
4914        if fresh == 0 || wraps.len() < REKEY_PAGE {
4915            break;
4916        }
4917        match oldest {
4918            Some(o) if o > 0 => until = Some(o),
4919            _ => break,
4920        }
4921    }
4922    Ok(out)
4923}
4924
4925/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
4926/// batch pairs the chunks fetched under one root with the continuity to demand of
4927/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
4928/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
4929/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
4930/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
4931/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
4932/// seal authority is the boundary). Among qualifying rotations carrying my blob
4933/// the lexicographically lowest new key wins (convergent). All complete
4934/// candidates without my blob conclude Removed for a KEYED holder only when one
4935/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
4936/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
4937/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
4938async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
4939    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
4940    scope: RekeyScope,
4941    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
4942    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
4943    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
4944    signer: &S,
4945    my_xonly: &[u8; 32],
4946    next_epoch: Epoch,
4947) -> Advance {
4948    let mut winners: Vec<[u8; 32]> = Vec::new();
4949    let mut saw_complete_candidate = false;
4950    let mut saw_outranking_candidate = false;
4951    let keyed = batches.iter().any(|(_, held)| held.is_some());
4952    for (chunks, held) in batches {
4953        let rotations = rekey::collect_rotations(chunks);
4954        for r in &rotations {
4955            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
4956                continue;
4957            }
4958            if let Some((held_epoch, held_key)) = held {
4959                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
4960                    continue;
4961                }
4962            }
4963            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
4964            // target. An authorized-but-inadmissible rotation (one that excludes
4965            // the owner or a peer/superior the rotator can't act on) is a takeover
4966            // attempt — skip it entirely, so it neither adopts nor concludes a
4967            // removal (it forks; the honest chain wins).
4968            if !admissible(r) {
4969                continue;
4970            }
4971            saw_complete_candidate = true;
4972            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
4973            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
4974                if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
4975                    winners.push(k);
4976                }
4977            }
4978        }
4979    }
4980    if !winners.is_empty() {
4981        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
4982        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
4983        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
4984        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
4985        // the same epoch (separate rotations): every follower converges on the same
4986        // lowest new key. A wrap served under two addressing roots can't double-count:
4987        // each rekey wrap opens under exactly one root's group key.
4988        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
4989        return Advance::Adopt { new_key: winners[idx] };
4990    }
4991    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
4992        Advance::Removed
4993    } else {
4994        Advance::Stay
4995    }
4996}
4997
4998#[cfg(test)]
4999mod tests {
5000    use super::super::super::transport::memory::MemoryRelay;
5001    use super::*;
5002    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
5003
5004    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
5005    fn account_name(n: u32) -> String {
5006        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5007        let mut acct = String::from("npub1");
5008        let mut v = n as usize;
5009        for _ in 0..58 {
5010            acct.push(B[v % 32] as char);
5011            v = v / 32 + 7;
5012        }
5013        acct
5014    }
5015
5016    /// One test participant: its identity keys and its isolated account DB dir.
5017    struct Actor {
5018        keys: Keys,
5019        account: String,
5020    }
5021
5022    /// Two participants sharing one relay but isolated per-account DBs — the
5023    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
5024    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
5025    /// the per-account id caches, so account A's community is invisible to B
5026    /// until B legitimately joins.
5027    struct TestBed {
5028        _tmp: tempfile::TempDir,
5029        _guard: std::sync::MutexGuard<'static, ()>,
5030        relay: MemoryRelay,
5031        relays: Vec<String>,
5032    }
5033
5034    impl TestBed {
5035        fn new() -> (TestBed, Actor, Actor) {
5036            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
5037            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5038            crate::db::close_database();
5039            crate::db::clear_id_caches();
5040            let tmp = tempfile::tempdir().unwrap();
5041            crate::db::set_app_data_dir(tmp.path().to_path_buf());
5042
5043            let mk = || {
5044                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5045                let account = account_name(n);
5046                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
5047                crate::db::set_current_account(account.clone()).unwrap();
5048                crate::db::init_database(&account).unwrap();
5049                Actor { keys: Keys::generate(), account }
5050            };
5051            let owner = mk();
5052            let member = mk();
5053            let _ = crate::state::take_nostr_client();
5054            let bed = TestBed {
5055                _tmp: tmp,
5056                _guard: guard,
5057                relay: MemoryRelay::new(),
5058                relays: vec!["wss://r".to_string()],
5059            };
5060            (bed, owner, member)
5061        }
5062
5063        /// Become `actor`: swap the account DB + identity, as a real session swap.
5064        /// Bumps the session generation like production `swap_session` does — so any task a
5065        /// prior actor spawned (e.g. the migration finalize) dies at its SessionGuard check
5066        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
5067        fn swap_to(&self, actor: &Actor) {
5068            crate::state::bump_session_generation();
5069            crate::db::set_current_account(actor.account.clone()).unwrap();
5070            crate::db::init_database(&actor.account).unwrap();
5071            crate::db::clear_id_caches();
5072            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
5073            crate::state::set_my_public_key(actor.keys.public_key());
5074        }
5075    }
5076
5077    /// Legacy single-actor helper (the create/send tests below).
5078    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
5079        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5080        crate::db::close_database();
5081        crate::db::clear_id_caches();
5082        let tmp = tempfile::tempdir().unwrap();
5083        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
5084        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5085        let acct = account_name(n);
5086        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5087        crate::db::set_app_data_dir(tmp.path().to_path_buf());
5088        crate::db::set_current_account(acct.clone()).unwrap();
5089        crate::db::init_database(&acct).unwrap();
5090        let _ = crate::state::take_nostr_client();
5091        let owner = Keys::generate();
5092        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
5093        crate::state::set_my_public_key(owner.public_key());
5094        (tmp, guard, owner)
5095    }
5096
5097    /// A transport that simulates a session swap landing DURING a fetch await —
5098    /// so a join straddling the fetch sees an invalid session and aborts.
5099    struct SwapMidFetch {
5100        inner: MemoryRelay,
5101    }
5102    #[async_trait::async_trait]
5103    impl Transport for SwapMidFetch {
5104        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5105        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5106            self.inner.publish(e, r).await
5107        }
5108        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5109            self.inner.publish_durable(e, r).await
5110        }
5111        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5112            let out = self.inner.fetch(q, r).await;
5113            crate::state::bump_session_generation();
5114            out
5115        }
5116    }
5117
5118    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
5119    /// the production `LiveTransport` union (first-responding relay's batch, no
5120    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
5121    /// the only harness that can exercise the revocation-race ordering.
5122    struct FixedFetch {
5123        events: Vec<Event>,
5124    }
5125    #[async_trait::async_trait]
5126    impl Transport for FixedFetch {
5127        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5128        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5129            Ok(())
5130        }
5131        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5132            Ok(())
5133        }
5134        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
5135            Ok(self.events.clone())
5136        }
5137    }
5138
5139    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
5140    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
5141    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
5142        let q = Query {
5143            kinds: vec![stream::KIND_WRAP],
5144            p_tags: vec![me.to_hex()],
5145            k_tags: vec!["3313".to_string()],
5146            ..Default::default()
5147        };
5148        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
5149    }
5150
5151    #[tokio::test]
5152    async fn create_persists_and_reloads_a_v2_community() {
5153        let (_tmp, _guard, owner) = init_test_db();
5154        let relay = MemoryRelay::new();
5155        let relays = vec!["wss://r".to_string()];
5156
5157        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
5158        assert!(created.identity.verify());
5159        assert_eq!(created.owner().unwrap(), owner.public_key());
5160        assert_eq!(created.channels.len(), 1);
5161
5162        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
5163        assert_eq!(
5164            crate::db::community::community_protocol(created.id()).unwrap(),
5165            Some(crate::community::ConcordProtocol::V2)
5166        );
5167        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
5168        assert_eq!(loaded.name, "Vectorville");
5169        assert_eq!(loaded.community_root, created.community_root);
5170        assert_eq!(loaded.identity, created.identity);
5171        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
5172        assert!(!loaded.channels[0].private);
5173
5174        // The genesis control editions + the owner Join landed on the relay.
5175        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
5176    }
5177
5178    #[tokio::test]
5179    async fn owner_sends_and_reads_back_a_message() {
5180        let (_tmp, _guard, _owner) = init_test_db();
5181        let relay = MemoryRelay::new();
5182        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
5183        let general = community.channels[0].id;
5184
5185        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
5186        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
5187        assert_ne!(id1, id2);
5188
5189        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
5190        let texts: Vec<String> = page
5191            .iter()
5192            .filter_map(|f| match &f.event {
5193                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
5194                _ => None,
5195            })
5196            .collect();
5197        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
5198    }
5199
5200    #[tokio::test]
5201    async fn a_second_member_reads_the_public_channel_from_the_root() {
5202        // A member who holds the community_root (via an invite bundle, modeled
5203        // here by cloning the community) reads the owner's public-channel message
5204        // — public channels need no key delivery, they derive from the root.
5205        let (_tmp, _guard, _owner) = init_test_db();
5206        let relay = MemoryRelay::new();
5207        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
5208        let general = community.channels[0].id;
5209        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
5210
5211        // The "member" reconstructs the same read coordinates from the root.
5212        let member_view = community.clone();
5213        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
5214        assert_eq!(page.len(), 1);
5215        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
5216        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
5217    }
5218
5219    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
5220
5221    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
5222        fetch_channel(relay, community, channel, 100)
5223            .await
5224            .unwrap()
5225            .iter()
5226            .filter_map(|f| match &f.event {
5227                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
5228                _ => None,
5229            })
5230            .collect()
5231    }
5232
5233    #[tokio::test]
5234    async fn direct_invite_full_loop_owner_and_member_converse() {
5235        let (bed, owner, member) = TestBed::new();
5236
5237        // Owner creates a community, posts, and Direct-Invites the member's npub.
5238        bed.swap_to(&owner);
5239        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
5240        let general = community.channels[0].id;
5241        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
5242        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5243
5244        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
5245        bed.swap_to(&member);
5246        assert!(
5247            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
5248            "the member does not hold the community before joining"
5249        );
5250        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5251        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5252        assert_eq!(joined.id().0, community.id().0, "joined the same community");
5253        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
5254        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
5255
5256        // The member reads the owner's public-channel history and replies.
5257        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
5258        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
5259
5260        // The owner reads the member's reply.
5261        bed.swap_to(&owner);
5262        assert_eq!(
5263            texts_in(&bed.relay, &community, &general).await,
5264            vec!["owner: welcome!", "member: thanks for the invite"],
5265            "both actors' messages interleave in ms order on the shared channel"
5266        );
5267
5268        // The Guestbook memberlist now folds both participants.
5269        let members = memberlist(&bed.relay, &community).await.unwrap();
5270        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
5271        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
5272        assert_eq!(members.len(), 2);
5273    }
5274
5275    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
5276    /// refuses to join — no Guestbook Join publish, no local write — through the shared
5277    /// accept path every door (direct invite, parked, public link, migration) funnels into.
5278    #[tokio::test]
5279    async fn a_banned_member_is_refused_at_join_time() {
5280        let (bed, owner, member) = TestBed::new();
5281
5282        bed.swap_to(&owner);
5283        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
5284        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5285        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
5286
5287        bed.swap_to(&member);
5288        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5289        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
5290        assert!(err.contains("banned"), "refusal names the reason: {err}");
5291        assert!(
5292            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
5293            "a refused join persists nothing"
5294        );
5295
5296        // The gate is the LAST word only for banned members: an unbanned bystander with
5297        // the same invite path still joins (the gate doesn't over-refuse).
5298        bed.swap_to(&owner);
5299        set_banlist(&bed.relay, &community, &[]).await.unwrap();
5300        bed.swap_to(&member);
5301        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5302        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
5303    }
5304
5305    /// End-to-end member migration: a member holding a v1 community folds the owner's
5306    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
5307    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
5308    #[tokio::test]
5309    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
5310        use crate::community::migration;
5311        let (bed, owner, member) = TestBed::new();
5312
5313        // Owner builds the v2 twin (real, verifiable on the shared relay).
5314        bed.swap_to(&owner);
5315        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
5316        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
5317        let jm = join_material(&v2);
5318
5319        // The member holds a v1 community owned by the SAME owner identity (the migration
5320        // premise) — construct + save it, and hold its server root.
5321        bed.swap_to(&member);
5322        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5323        let v1_cid = v1.id.to_hex();
5324        v1.owner_attestation = Some({
5325            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5326                .finalize(&owner.keys).unwrap().as_json()
5327        });
5328        crate::db::community::save_community(&v1).unwrap();
5329        let v1_channel = v1.channels[0].id.to_hex();
5330
5331        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
5332        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
5333        let signpost = migration::MigrationSignpost {
5334            v2_community_id: v2_hex.clone(),
5335            owner_xonly: owner.keys.public_key().to_hex(),
5336            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
5337            relays: bed.relays.clone(),
5338            name: "Guild".into(),
5339            primary_channel: v1_channel.clone(),
5340            root_epoch: 0,
5341        };
5342        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
5343        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
5344
5345        // Drive the migration: opens m, joins v2 (ban-gated), flips.
5346        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
5347        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
5348
5349        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
5350        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
5351        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
5352        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
5353        let _ = v1_channel;
5354
5355        // Idempotent: a second drive is a no-op (already flipped).
5356        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
5357    }
5358
5359    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
5360    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
5361    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
5362    /// couldn't (that twin had mismatched ids).
5363    #[tokio::test]
5364    async fn owner_wizard_then_member_migrate_and_stitch() {
5365        use crate::community::migration;
5366        let (bed, owner, member) = TestBed::new();
5367        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5368
5369        // Owner holds a v1 community (they created it) with one channel.
5370        bed.swap_to(&owner);
5371        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5372        let v1_cid = v1.id.to_hex();
5373        let v1_channel = v1.channels[0].id.to_hex();
5374        v1.owner_attestation = Some({
5375            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5376                .finalize(&owner.keys).unwrap().as_json()
5377        });
5378        crate::db::community::save_community(&v1).unwrap();
5379
5380        // Run the wizard.
5381        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5382        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
5383            "owner's own client flipped to v2");
5384        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
5385        // primary channel REUSES the v1 channel id.
5386        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
5387            "owner channel stitched to v2");
5388
5389        // A MEMBER holding the same v1 community folds the carrier and migrates.
5390        bed.swap_to(&member);
5391        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5392        // The member's v1 community must be the SAME id + root the owner published under.
5393        m_v1.id = v1.id;
5394        m_v1.server_root_key = v1.server_root_key.clone();
5395        m_v1.channels[0].id = v1.channels[0].id;
5396        m_v1.owner_attestation = v1.owner_attestation.clone();
5397        crate::db::community::save_community(&m_v1).unwrap();
5398
5399        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
5400        // AND auto-drives the flip — the live one-event member experience, no manual step.
5401        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
5402        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
5403        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
5404            "the FOLD ITSELF flipped the member (auto-drive)");
5405        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
5406            "member holds the v2 twin");
5407        // A manual re-drive is an idempotent no-op.
5408        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
5409    }
5410
5411    /// The wizard records the twin in the cross-device community list, like every other v2
5412    /// join/create path. Sibling devices normally discover the twin by folding the carrier
5413    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
5414    /// the list is its only route in.
5415    #[tokio::test]
5416    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
5417        use crate::community::migration;
5418        let (bed, owner, _member) = TestBed::new();
5419        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5420
5421        bed.swap_to(&owner);
5422        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5423        let v1_cid = v1.id.to_hex();
5424        v1.owner_attestation = Some({
5425            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5426                .finalize(&owner.keys).unwrap().as_json()
5427        });
5428        crate::db::community::save_community(&v1).unwrap();
5429
5430        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5431
5432        // The twin is live in the published list, so a fresh/carrier-less device finds it.
5433        let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
5434            .expect("the wizard published a community list");
5435        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
5436        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
5437        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
5438        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
5439        assert!(
5440            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
5441            "migration must not tombstone the v1 community"
5442        );
5443    }
5444
5445    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
5446    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
5447    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
5448    /// (the double-mint orphan) and race its flip against the first.
5449    #[tokio::test]
5450    async fn wizard_refuses_while_a_drive_holds_the_claim() {
5451        use crate::community::migration;
5452        let (bed, owner, _member) = TestBed::new();
5453        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5454
5455        bed.swap_to(&owner);
5456        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5457        let v1_cid = v1.id.to_hex();
5458        v1.owner_attestation = Some({
5459            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5460                .finalize(&owner.keys).unwrap().as_json()
5461        });
5462        crate::db::community::save_community(&v1).unwrap();
5463
5464        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
5465        migration::test_hold_drive_claim(&v1_cid);
5466        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
5467        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
5468        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
5469        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
5470        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
5471
5472        // Once the drive releases, the wizard runs normally.
5473        migration::test_release_drive_claim(&v1_cid);
5474        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5475        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
5476    }
5477
5478    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
5479    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
5480    /// channel-less struct — pruning exactly the rows the flip just re-parented).
5481    /// Proves the lock actually serializes rather than being a no-op: with the lock held
5482    /// the wizard cannot reach its flip, and it completes once released.
5483    #[tokio::test]
5484    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
5485        use crate::community::migration;
5486        let (bed, owner, _member) = TestBed::new();
5487        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5488        // Shared across the spawned wizard, so both halves see the same relay state.
5489        let relay = std::sync::Arc::new(MemoryRelay::new());
5490
5491        bed.swap_to(&owner);
5492        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5493        let v1_cid = v1.id.to_hex();
5494        let v1_channel = v1.channels[0].id.to_hex();
5495        v1.owner_attestation = Some({
5496            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5497                .finalize(&owner.keys).unwrap().as_json()
5498        });
5499        crate::db::community::save_community(&v1).unwrap();
5500
5501        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
5502        // the flip runs — exactly what a follow worker would have loaded.
5503        let twin = create_migration_twin(
5504            &*relay, "Guild", bed.relays.clone(), None,
5505            (v1.channels[0].id, "general".to_string()),
5506        ).await.unwrap();
5507        let v2_id = twin.identity.community_id;
5508        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
5509        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
5510
5511        // A follow pass is in flight: it holds the lock across its network stage.
5512        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
5513
5514        let wizard = tokio::spawn({
5515            let relay = relay.clone();
5516            let v1 = v1.clone();
5517            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
5518        });
5519
5520        // The wizard runs its network phases but must BLOCK at the flip.
5521        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
5522        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
5523        assert!(
5524            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
5525            "the fence must not be stamped while the follow lock is held"
5526        );
5527
5528        // The follow pass finishes; the flip proceeds.
5529        drop(held);
5530        let flipped = wizard.await.unwrap().unwrap();
5531        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
5532        assert_eq!(
5533            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
5534            Some(v2_hex.as_str()),
5535            "the channel row is stitched to the twin, not pruned"
5536        );
5537    }
5538
5539    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
5540    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
5541    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
5542    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
5543    #[tokio::test]
5544    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
5545        use crate::community::migration;
5546        let (bed, owner, banned) = TestBed::new();
5547        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5548
5549        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
5550        bed.swap_to(&owner);
5551        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5552        let v1_cid = v1.id.to_hex();
5553        v1.owner_attestation = Some({
5554            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5555                .finalize(&owner.keys).unwrap().as_json()
5556        });
5557        crate::db::community::save_community(&v1).unwrap();
5558        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
5559
5560        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5561
5562        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
5563        bed.swap_to(&banned);
5564        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5565        m_v1.id = v1.id;
5566        m_v1.server_root_key = v1.server_root_key.clone();
5567        m_v1.channels[0].id = v1.channels[0].id;
5568        m_v1.owner_attestation = v1.owner_attestation.clone();
5569        crate::db::community::save_community(&m_v1).unwrap();
5570        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
5571
5572        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
5573        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
5574        let payload = migration::parse_migration_payload(&raw).unwrap();
5575        assert!(payload.m.is_some());
5576        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
5577        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
5578        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
5579        assert!(
5580            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
5581            "banned member never acquires the v2 twin"
5582        );
5583    }
5584
5585    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
5586    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
5587    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
5588    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
5589    #[tokio::test]
5590    async fn wizard_resume_continues_on_the_same_twin() {
5591        use crate::community::migration;
5592        let (bed, owner, banned) = TestBed::new();
5593        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5594
5595        bed.swap_to(&owner);
5596        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5597        // A second channel + a banned member make the resumed phase-1b tail REAL work.
5598        let mut sibling = v1.channels[0].clone();
5599        sibling.id = crate::community::ChannelId(crate::community::random_32());
5600        sibling.name = "offtopic".into();
5601        v1.channels.push(sibling.clone());
5602        let v1_cid = v1.id.to_hex();
5603        v1.owner_attestation = Some({
5604            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5605                .finalize(&owner.keys).unwrap().as_json()
5606        });
5607        crate::db::community::save_community(&v1).unwrap();
5608        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
5609
5610        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
5611        // BEFORE the sibling channel + banlist clone ever ran.
5612        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
5613        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
5614        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
5615
5616        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
5617        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5618        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
5619        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
5620        assert_eq!(ledger_v2, minted_hex);
5621        assert_eq!(phase, migration::PHASE_FLIPPED);
5622        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
5623        // (folding the twin's control plane yields the banned npub).
5624        assert_eq!(
5625            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
5626            Some(minted_hex.as_str()),
5627            "sibling channel re-parented by the resumed run"
5628        );
5629        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
5630        let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
5631            .await
5632            .map(|(c, h, b)| (c, h, b))
5633            .unwrap();
5634        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
5635            "the resumed banlist clone is folded from the twin's wire control plane");
5636
5637        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
5638        // → the re-run reports SUCCESS and heals, never "already been migrated".
5639        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
5640        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5641        assert_eq!(healed, minted_hex);
5642        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
5643        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
5644
5645        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
5646        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
5647        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
5648        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
5649        // fresh v1 owned by the same owner.
5650        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
5651        let v1b_cid = v1b.id.to_hex();
5652        v1b.owner_attestation = Some({
5653            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
5654                .finalize(&owner.keys).unwrap().as_json()
5655        });
5656        crate::db::community::save_community(&v1b).unwrap();
5657        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
5658        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
5659        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
5660        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
5661        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
5662        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
5663        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
5664    }
5665
5666    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
5667    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
5668    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
5669    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
5670    #[tokio::test]
5671    async fn birth_refound_seeds_an_explicit_roster() {
5672        let (bed, owner, _m) = TestBed::new();
5673        bed.swap_to(&owner);
5674        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5675            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5676        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
5677        // Two strangers who never join — pure seeded members.
5678        let ghost_a = Keys::generate().public_key();
5679        let ghost_b = Keys::generate().public_key();
5680
5681        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
5682        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
5683
5684        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
5685        // ever published a Join.
5686        let members = memberlist(&bed.relay, &rolled).await.unwrap();
5687        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
5688        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
5689
5690        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
5691        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
5692        // a fresh twin's snapshot only counts because the owner minted epoch 1.
5693        let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
5694            .expect("the epoch-1 twin verifies from its compacted control plane");
5695
5696        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
5697        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
5698        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
5699        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
5700        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
5701    }
5702
5703    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
5704    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
5705    /// it before the snapshot, so the refound still completes instead of aborting forever.
5706    #[tokio::test]
5707    async fn birth_refound_ignores_a_banned_seed_entry() {
5708        let (bed, owner, _m) = TestBed::new();
5709        bed.swap_to(&owner);
5710        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5711            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5712        let good = Keys::generate().public_key();
5713        let banned = Keys::generate();
5714        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
5715        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
5716        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
5717
5718        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
5719            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
5720        assert_eq!(rolled.root_epoch, Epoch(1));
5721        let members = memberlist(&bed.relay, &rolled).await.unwrap();
5722        assert!(members.contains(&good), "the non-banned seed lands");
5723        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
5724    }
5725
5726    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
5727    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
5728    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
5729    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
5730    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
5731    #[tokio::test]
5732    async fn a_seeded_member_receives_a_later_refound_rekey() {
5733        let (bed, owner, _m) = TestBed::new();
5734        bed.swap_to(&owner);
5735        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
5736            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
5737        let ghost = Keys::generate();
5738        // Birth refound seeds the ghost (never joins, holds no keys).
5739        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
5740        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
5741
5742        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
5743        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
5744        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
5745        // memberlist proves both: the refound saw them as a member and carried them forward, so
5746        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
5747        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
5748        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
5749        assert!(
5750            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
5751            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
5752        );
5753    }
5754
5755    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
5756    /// MANAGE_ROLES there), while a plain member is not.
5757    #[tokio::test]
5758    async fn v1_admin_stays_admin_across_migration() {
5759        use crate::community::migration;
5760        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5761        let (bed, owner, admin) = TestBed::new();
5762        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
5763
5764        bed.swap_to(&owner);
5765        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5766        let v1_cid = v1.id.to_hex();
5767        v1.owner_attestation = Some({
5768            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5769                .finalize(&owner.keys).unwrap().as_json()
5770        });
5771        crate::db::community::save_community(&v1).unwrap();
5772        // v1 governance: one Admin role, granted to `admin`.
5773        let admin_role = Role::admin("a1".repeat(32));
5774        let roles = CommunityRoles {
5775            roles: vec![admin_role.clone()],
5776            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
5777        };
5778        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
5779
5780        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
5781        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
5782
5783        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
5784        let authority = fetch_authority(&bed.relay, &twin).await;
5785        assert!(
5786            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
5787            "the v1 admin is an admin on the v2 twin"
5788        );
5789        assert!(
5790            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
5791            "a non-admin gains no authority"
5792        );
5793    }
5794
5795    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
5796    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
5797    /// probe returning only a stranger's record would permanently stop the sweep before the
5798    /// owner's real carrier is ever fetched.
5799    #[tokio::test]
5800    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
5801        use crate::community::migration;
5802        let (bed, owner, stranger) = TestBed::new();
5803
5804        bed.swap_to(&owner);
5805        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5806        let v1_cid = v1.id.to_hex();
5807        v1.owner_attestation = Some({
5808            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
5809                .finalize(&owner.keys).unwrap().as_json()
5810        });
5811        crate::db::community::save_community(&v1).unwrap();
5812
5813        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
5814        // the community is locally sealed (as if folded on an old build) but not yet checked.
5815        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
5816        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
5817        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
5818        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
5819
5820        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
5821        migration::sweep_dissolved_for_migration(&bed.relay).await;
5822        assert!(
5823            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
5824            "a stranger-only probe must not converge the sweep"
5825        );
5826
5827        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
5828        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
5829        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
5830        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
5831        migration::sweep_dissolved_for_migration(&bed.relay).await;
5832        assert!(
5833            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
5834            "an owner plain-dissolution converges the sweep"
5835        );
5836    }
5837
5838    /// Wizard preflight refuses before the timelock and for non-owners.
5839    #[tokio::test]
5840    async fn wizard_preflight_gates_timelock_and_ownership() {
5841        use crate::community::migration;
5842        let (bed, owner, _member) = TestBed::new();
5843        bed.swap_to(&owner);
5844        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
5845        v1.owner_attestation = Some({
5846            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
5847                .finalize(&owner.keys).unwrap().as_json()
5848        });
5849        crate::db::community::save_community(&v1).unwrap();
5850
5851        // Before the unlock → refused, nothing published.
5852        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
5853        assert!(err.contains("not unlocked"), "{err}");
5854        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
5855    }
5856
5857    #[tokio::test]
5858    async fn public_link_full_loop() {
5859        let (bed, owner, member) = TestBed::new();
5860
5861        bed.swap_to(&owner);
5862        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
5863        let general = community.channels[0].id;
5864        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
5865        // Mint a shareable link (a non-stock relay so the fragment carries it).
5866        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5867        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
5868        assert!(link.url.contains('#'), "the fragment carries the token");
5869
5870        // Member joins purely from the URL string.
5871        bed.swap_to(&member);
5872        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5873        assert_eq!(joined.id().0, community.id().0);
5874        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
5875    }
5876
5877    #[test]
5878    fn bundle_of_snapshots_the_held_icon() {
5879        let owner = Keys::generate();
5880        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
5881        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
5882        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
5883        c.icon = Some(icon.clone());
5884        let bundle = bundle_of(&c, None, None, None);
5885        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
5886    }
5887
5888    #[test]
5889    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
5890        // follow_rekeys' fetch fan AND streamauth's plane registration share
5891        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
5892        // MUST include archived roots or an AUTH-gated relay never serves the
5893        // rotation crate → the channel stalls at its old epoch.
5894        let (_tmp, _guard, _owner) = init_test_db();
5895        let cur_root = [9u8; 32];
5896        let cid = crate::community::CommunityId([1u8; 32]);
5897        let cid_hex = cid.to_hex();
5898
5899        // No archives yet → just the current root.
5900        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5901        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
5902
5903        // Archive two prior roots (freshest-first ordering is asserted below).
5904        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
5905        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
5906        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5907        assert_eq!(roots[0], cur_root, "current root leads");
5908        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
5909        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
5910        // Freshest-archived-first (epoch 1 before epoch 0).
5911        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
5912
5913        // A stored root equal to the CURRENT one must not duplicate.
5914        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
5915        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5916        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
5917
5918        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
5919        for e in 3..20u64 {
5920            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
5921        }
5922        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
5923        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
5924    }
5925
5926    #[tokio::test]
5927    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
5928        let (bed, owner, member) = TestBed::new();
5929
5930        bed.swap_to(&owner);
5931        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
5932        // The icon lives on the Control Plane, never in the bundle — publish it
5933        // as a metadata edition so the preview must FOLD to see it.
5934        let icon = control::ImageRef {
5935            url: "https://blossom.example/soap".into(),
5936            key: "k".into(),
5937            nonce: "n".into(),
5938            hash: "h".into(),
5939            extra: Default::default(),
5940        };
5941        let mut meta = community.metadata();
5942        meta.icon = Some(icon.clone());
5943        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5944        // An any-host base — the naddr#fragment payload is domain-agnostic.
5945        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
5946
5947        // A NON-member previews: the real name + the live icon, nothing persisted.
5948        bed.swap_to(&member);
5949        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
5950        assert_eq!(preview.name, "Soapbox");
5951        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
5952        assert!(
5953            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
5954            "previewing must not persist a membership"
5955        );
5956    }
5957
5958    #[tokio::test]
5959    async fn a_previewed_join_reuses_the_verified_fold() {
5960        let (bed, owner, member) = TestBed::new();
5961        bed.swap_to(&owner);
5962        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
5963        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5964
5965        bed.swap_to(&member);
5966        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
5967        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5968        assert_eq!(joined.id().0, community.id().0);
5969        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
5970        // The slot was CONSUMED by the join — proving the handoff path ran (a
5971        // verify re-walk would have left the preview's entry in place).
5972        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
5973    }
5974
5975    #[tokio::test]
5976    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
5977        let (bed, owner, member) = TestBed::new();
5978        bed.swap_to(&owner);
5979        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
5980        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5981
5982        bed.swap_to(&member);
5983        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
5984
5985        // Seed from zero: the stored fold equals the authoritative live fold.
5986        let session = SessionGuard::capture();
5987        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
5988        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5989        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
5990        assert!(cursor > 0, "the cursor advanced past zero");
5991        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
5992        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
5993        assert_eq!(stored, live, "stored fold == live fold after the seed");
5994        assert!(stored.contains(&member.keys.public_key()));
5995
5996        // Nothing new on the plane → an idle re-sync folds nothing.
5997        assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
5998
5999        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
6000        bed.swap_to(&owner);
6001        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6002        bed.swap_to(&member);
6003        let session = SessionGuard::capture();
6004        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
6005        assert!(
6006            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
6007            "an owner kick removes the member from the stored fold"
6008        );
6009    }
6010
6011    #[tokio::test]
6012    async fn a_preview_then_revoke_still_refuses_the_join() {
6013        let (bed, owner, member) = TestBed::new();
6014        bed.swap_to(&owner);
6015        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
6016        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6017
6018        // Member previews (warming the verified handoff), THEN the owner revokes.
6019        bed.swap_to(&member);
6020        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
6021        assert_eq!(p.name, "RevokeRace");
6022        bed.swap_to(&owner);
6023        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6024        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6025
6026        // The join MUST refuse: the handoff skips only the root re-verify, never
6027        // the bundle re-fetch that carries the revocation gate.
6028        bed.swap_to(&member);
6029        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6030        assert!(err.contains("revoked"), "got: {err}");
6031    }
6032
6033    #[tokio::test]
6034    async fn a_revoked_link_refuses_to_join() {
6035        let (bed, owner, member) = TestBed::new();
6036        bed.swap_to(&owner);
6037        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
6038        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6039        // Owner retires the link (re-posts the coordinate as a tombstone).
6040        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6041        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6042
6043        bed.swap_to(&member);
6044        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6045        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
6046    }
6047
6048    #[tokio::test]
6049    async fn an_expired_direct_invite_refuses_to_join() {
6050        let (bed, owner, member) = TestBed::new();
6051        bed.swap_to(&owner);
6052        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
6053        // Hand-mint an invite that expired in the past.
6054        let inviter = owner.keys.clone();
6055        let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
6056        bundle.expires_at = Some(1_000); // unix ms, long past
6057        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
6058        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6059
6060        bed.swap_to(&member);
6061        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6062        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6063        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
6064    }
6065
6066    #[tokio::test]
6067    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
6068        // The revocation-durability fix: if ANY signer-valid tombstone is among the
6069        // fetched events, refuse — even when a Live bundle is returned FIRST (the
6070        // production union has no newest-first sort, so a stale relay's Live can lead).
6071        let (bed, owner, member) = TestBed::new();
6072        bed.swap_to(&owner);
6073        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
6074        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6075        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6076
6077        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
6078        // `events.first()` would join the Live; the scan-all fix must refuse.
6079        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
6080
6081        bed.swap_to(&member);
6082        let err = accept_public_link(&union, &link.url).await.unwrap_err();
6083        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
6084    }
6085
6086    #[test]
6087    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
6088        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
6089        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
6090        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
6091        // cap, which trips even earlier — but the count guard is the real defense
6092        // for the single-layer public-link bundle.)
6093        let owner = Keys::generate();
6094        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6095        let hex = crate::simd::hex::bytes_to_hex_32;
6096        let root = [0x11u8; 32];
6097        let mut bundle = CommunityInvite {
6098            community_id: hex(&identity.community_id.0),
6099            owner: hex(&identity.owner_xonly),
6100            owner_salt: hex(&identity.owner_salt),
6101            community_root: hex(&root),
6102            root_epoch: 0,
6103            channels: vec![],
6104            relays: vec!["wss://r".into()],
6105            name: "X".into(),
6106            icon: None,
6107            expires_at: None,
6108            creator_npub: None,
6109            label: None,
6110            extra: Default::default(),
6111        };
6112        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
6113            .map(|i| {
6114                let mut id = [0u8; 32];
6115                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
6116                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
6117            })
6118            .collect();
6119        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
6120    }
6121
6122    #[tokio::test]
6123    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
6124        // The SessionGuard straddle: a public-link accept fetches then saves. If the
6125        // account swaps in that window, the join must abort — never write A's
6126        // community into B's DB. SwapMidFetch bumps the session generation during
6127        // the fetch await, exactly as a real swap_session would.
6128        let (bed, owner, member) = TestBed::new();
6129        bed.swap_to(&owner);
6130        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
6131        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6132        // A fresh swap-injecting transport holding the same bundle event.
6133        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6134        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
6135
6136        bed.swap_to(&member);
6137        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
6138        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
6139        assert!(
6140            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6141            "the aborted join wrote nothing to the (member) account DB"
6142        );
6143    }
6144
6145    #[tokio::test]
6146    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
6147        // The owner is derived from the self-certifying community_id, so the
6148        // memberlist includes them independent of any Guestbook fetch.
6149        let (_tmp, _guard, owner) = init_test_db();
6150        let relay = MemoryRelay::new();
6151        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
6152        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
6153        // still contains the owner.
6154        let empty = MemoryRelay::new();
6155        let members = memberlist(&empty, &community).await.unwrap();
6156        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
6157    }
6158
6159    #[tokio::test]
6160    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
6161        // The mint path can now produce an expiring invite, and the accept gate
6162        // trips on it (end-to-end through the real service, not a hand-built bundle).
6163        let (bed, owner, member) = TestBed::new();
6164        bed.swap_to(&owner);
6165        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
6166        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
6167            .await
6168            .unwrap();
6169
6170        bed.swap_to(&member);
6171        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6172        assert!(
6173            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
6174            "a minted expiring invite refuses past its deadline"
6175        );
6176    }
6177
6178    #[tokio::test]
6179    async fn a_member_who_leaves_drops_from_the_memberlist() {
6180        let (bed, owner, member) = TestBed::new();
6181        bed.swap_to(&owner);
6182        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
6183        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6184
6185        bed.swap_to(&member);
6186        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6187        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6188        // Let the leave land strictly after the join.
6189        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
6190        leave_community(&bed.relay, &joined).await.unwrap();
6191
6192        bed.swap_to(&owner);
6193        let members = memberlist(&bed.relay, &community).await.unwrap();
6194        assert!(members.contains(&owner.keys.public_key()));
6195        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
6196    }
6197
6198    #[tokio::test]
6199    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
6200        // Multi-account isolation: after the swap, the member's DB holds nothing
6201        // of the owner's community — the dual-stack storage is per-account.
6202        let (bed, owner, member) = TestBed::new();
6203        bed.swap_to(&owner);
6204        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
6205        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
6206
6207        bed.swap_to(&member);
6208        assert!(
6209            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6210            "the owner's community must be invisible in the member's account DB"
6211        );
6212        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
6213    }
6214
6215    // ── Live control-follow ──────────────────────────────────────────────────
6216
6217    /// Publish an owner-grammar channel edition straight to the control plane,
6218    /// signed by `signer` (the owner for a legit edit, a stranger for the
6219    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
6220    /// The entity's current head `self_hash` on the relay (highest version wins),
6221    /// so a helper can chain a new edition the way a real owner client does.
6222    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
6223        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6224        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6225        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
6226        let mut head: Option<(u64, [u8; 32])> = None;
6227        for w in &wraps {
6228            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6229                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
6230                    head = Some((ed.version, ed.self_hash));
6231                }
6232            }
6233        }
6234        head.map(|(_, h)| h)
6235    }
6236
6237    /// The `vac` a non-owner signer must attach, read off the Grant they were
6238    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
6239    /// real client does via `my_authority_citation`, so the fixtures publish the
6240    /// shape Vector actually emits.
6241    async fn cite_on_relay(
6242        relay: &MemoryRelay,
6243        community: &CommunityV2,
6244        signer: &Keys,
6245    ) -> Option<crate::community::edition::AuthorityCitation> {
6246        if community.owner().ok() == Some(signer.public_key()) {
6247            return None;
6248        }
6249        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
6250        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6251        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6252        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
6253        let mut head: Option<(u64, [u8; 32])> = None;
6254        for w in &wraps {
6255            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6256                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
6257                    head = Some((ed.version, ed.self_hash));
6258                }
6259            }
6260        }
6261        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
6262    }
6263
6264    async fn publish_channel_edition(
6265        relay: &MemoryRelay,
6266        community: &CommunityV2,
6267        signer: &Keys,
6268        channel_id: &ChannelId,
6269        name: &str,
6270        private: bool,
6271        version: u64,
6272        deleted: bool,
6273    ) {
6274        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6275        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
6276        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
6277        let content = serde_json::to_string(&meta).unwrap();
6278        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
6279        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6280        relay.publish(&wrap, &community.relays).await.unwrap();
6281    }
6282
6283    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
6284    /// to the current relay head like a real owner client.
6285    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
6286        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
6287    }
6288
6289    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
6290    /// relay-side newest-first ordering (paging/eviction scenarios).
6291    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
6292        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6293        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
6294        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
6295        let content = serde_json::to_string(&meta).unwrap();
6296        let cite = cite_on_relay(relay, community, signer).await;
6297        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());
6298        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
6299        relay.publish(&wrap, &community.relays).await.unwrap();
6300    }
6301
6302    #[test]
6303    fn metadata_apply_captures_undriven_fields_for_republish() {
6304        let owner = Keys::generate();
6305        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
6306        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
6307        let general = held.channels[0].id;
6308
6309        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
6310        let mut custom = serde_json::Map::new();
6311        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
6312        let mut extra = serde_json::Map::new();
6313        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
6314        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
6315        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
6316        assert_eq!(held.meta_custom, Some(custom.clone()));
6317        assert_eq!(held.meta_extra, extra);
6318        // …and the next local edit's base document republishes them verbatim.
6319        assert_eq!(held.metadata().custom, Some(custom));
6320        assert_eq!(held.metadata().extra, held.meta_extra);
6321
6322        // Same contract for a vsk-2 channel head (voice included).
6323        let mut ch_custom = serde_json::Map::new();
6324        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
6325        let ch_meta = control::ChannelMetadata {
6326            name: "general".into(),
6327            private: false,
6328            voice: Some(true),
6329            deleted: None,
6330            custom: Some(ch_custom.clone()),
6331            extra: Default::default(),
6332        };
6333        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
6334        let ch = held.channel(&general).unwrap();
6335        assert_eq!(ch.voice, Some(true));
6336        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
6337        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
6338        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
6339        assert_eq!(rename.custom, Some(ch_custom));
6340    }
6341
6342    #[test]
6343    fn community_metadata_apply_sets_and_clears_images() {
6344        let owner = Keys::generate();
6345        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
6346        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
6347
6348        let icon = control::ImageRef {
6349            url: "https://blossom.example/i".into(),
6350            key: "k".into(),
6351            nonce: "n".into(),
6352            hash: "h".into(),
6353            extra: Default::default(),
6354        };
6355        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
6356        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
6357        assert_eq!(held.icon.as_ref(), Some(&icon));
6358
6359        // An edition is the FULL document: a head without the icon removes it.
6360        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
6361        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
6362        assert_eq!(held.icon, None);
6363    }
6364
6365    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
6366    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
6367        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6368        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
6369        let prev = head_hash_on_relay(relay, community, &role_id).await;
6370        let content = crate::community::v2::roles::role_content_json(role).unwrap();
6371        let cite = cite_on_relay(relay, community, signer).await;
6372        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6373        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6374        relay.publish(&wrap, &community.relays).await.unwrap();
6375    }
6376
6377    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
6378    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
6379        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6380        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
6381        let prev = head_hash_on_relay(relay, community, &eid).await;
6382        let grant = MemberGrant { member: member.to_hex(), role_ids };
6383        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
6384        let cite = cite_on_relay(relay, community, signer).await;
6385        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6386        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6387        relay.publish(&wrap, &community.relays).await.unwrap();
6388    }
6389
6390    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
6391    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
6392        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6393        let eid = crate::community::v2::derive::banlist_locator(community.id());
6394        let prev = head_hash_on_relay(relay, community, &eid).await;
6395        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
6396        let cite = cite_on_relay(relay, community, signer).await;
6397        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
6398        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
6399        relay.publish(&wrap, &community.relays).await.unwrap();
6400    }
6401
6402    fn admin_role(role_id: &str, perms: u64) -> Role {
6403        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
6404    }
6405
6406    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
6407    // any control edition) must not be able to SUPPRESS a role or grant by forging a
6408    // higher version at its coordinate. Owner-only signers mask this entirely, so every
6409    // attacker below signs as a NON-owner member.
6410
6411    #[tokio::test]
6412    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
6413        let (bed, owner, attacker) = TestBed::new();
6414        bed.swap_to(&owner);
6415        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
6416        let victim = Keys::generate().public_key();
6417        grant_admin(&bed.relay, &community, &victim).await.unwrap();
6418
6419        // The admin role sits at a deterministic, publicly-computable coordinate.
6420        let admin_rid = fetch_authority(&bed.relay, &community)
6421            .await
6422            .roles
6423            .roles
6424            .iter()
6425            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
6426            .unwrap()
6427            .role_id
6428            .clone();
6429        // Attacker forges v2 of that exact role, stripping its powers.
6430        publish_role(
6431            &bed.relay,
6432            &community,
6433            &attacker.keys,
6434            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
6435            2,
6436        )
6437        .await;
6438
6439        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6440        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
6441        assert!(
6442            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
6443            "the floor advances only to the AUTHORIZED head (owner v1)"
6444        );
6445        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
6446    }
6447
6448    #[tokio::test]
6449    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
6450        let (bed, owner, attacker) = TestBed::new();
6451        bed.swap_to(&owner);
6452        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
6453        let victim = Keys::generate();
6454        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
6455
6456        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
6457        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
6458
6459        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6460        assert!(
6461            authority.roles.is_admin(&victim.public_key().to_hex()),
6462            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
6463        );
6464    }
6465
6466    #[tokio::test]
6467    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
6468        let (bed, owner, attacker) = TestBed::new();
6469        bed.swap_to(&owner);
6470        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
6471        let victim = Keys::generate().public_key();
6472        grant_admin(&bed.relay, &community, &victim).await.unwrap();
6473
6474        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
6475        // unauthorized, so the post-authorize cap never sees them.
6476        for i in 0u8..6 {
6477            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
6478            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6479        }
6480
6481        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
6482        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
6483        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
6484    }
6485
6486    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
6487    /// roster + banlist — two clients converge iff these match.
6488    fn authority_fingerprint(a: &AuthoritySet) -> String {
6489        let mut roles = a.roles.roles.clone();
6490        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
6491        let mut grants = a.roles.grants.clone();
6492        for g in &mut grants {
6493            g.role_ids.sort();
6494        }
6495        grants.sort_by(|x, y| x.member.cmp(&y.member));
6496        let banned: Vec<&String> = a.banned.iter().collect();
6497        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
6498    }
6499
6500    #[tokio::test]
6501    async fn the_v2_authority_fold_is_order_independent() {
6502        // THE core consensus property: two honest clients that receive the SAME
6503        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
6504        // authorized roster + banlist (author-aware select_authorized + banlist
6505        // fold + cap, all deterministic). A divergence here would fork the
6506        // community's moderation state between honest members.
6507        let (bed, owner, _a) = TestBed::new();
6508        bed.swap_to(&owner);
6509        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
6510
6511        // A rich control plane: two admins, an extra role, two grants (one of them a
6512        // grant to a member the owner then bans), a banlist, a rename, a channel.
6513        let admin1 = Keys::generate().public_key();
6514        let admin2 = Keys::generate().public_key();
6515        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
6516        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
6517        let mod_rid = "5c".repeat(32);
6518        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
6519        let member = Keys::generate().public_key();
6520        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
6521        let banned_member = Keys::generate().public_key();
6522        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
6523        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
6524        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
6525        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
6526        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
6527
6528        let editions = fetch_control(&bed.relay, &community).await;
6529        let floors = load_floors(&community);
6530        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
6531
6532        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
6533
6534        // Fold under many arrival permutations: reversed, and several deterministic
6535        // rotations/interleavings. Every one must match the baseline.
6536        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
6537        let mut rev = editions.clone();
6538        rev.reverse();
6539        orders.push(rev);
6540        for shift in [1usize, 3, 5, 7] {
6541            let n = editions.len();
6542            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
6543        }
6544        // A deterministic "shuffle": interleave from both ends.
6545        let mut zip = Vec::with_capacity(editions.len());
6546        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
6547        while lo <= hi {
6548            zip.push(editions[lo as usize].clone());
6549            if lo != hi {
6550                zip.push(editions[hi as usize].clone());
6551            }
6552            lo += 1;
6553            hi -= 1;
6554        }
6555        orders.push(zip);
6556
6557        for (i, order) in orders.iter().enumerate() {
6558            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
6559            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
6560        }
6561        // Sanity: the fingerprint reflects real state (the banned member is out, the
6562        // honest admins are in).
6563        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
6564        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
6565    }
6566
6567    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
6568    struct FetchErrors(MemoryRelay);
6569    #[async_trait::async_trait]
6570    impl crate::community::transport::Transport for FetchErrors {
6571        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6572        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6573            self.0.publish(e, r).await
6574        }
6575        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6576            Err("relay down".to_string())
6577        }
6578        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6579            self.0.publish_durable(e, r).await
6580        }
6581    }
6582
6583    #[tokio::test]
6584    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
6585        let (bed, owner, victim) = TestBed::new();
6586        bed.swap_to(&owner);
6587        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
6588        let victim_hex = victim.keys.public_key().to_hex();
6589        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
6590        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6591        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
6592
6593        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
6594        // return an empty banlist (which would silently un-ban on withheld data).
6595        let down = FetchErrors(MemoryRelay::new());
6596        let view = fetch_authority(&down, &community).await;
6597        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
6598    }
6599
6600    #[tokio::test]
6601    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
6602        let (bed, owner, _m) = TestBed::new();
6603        bed.swap_to(&owner);
6604        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
6605        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6606        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
6607        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
6608
6609        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
6610        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6611        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
6612        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
6613        let session = crate::state::SessionGuard::capture();
6614        follow_control(&bed.relay, &community, &session).await.unwrap();
6615        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
6616
6617        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
6618        // drops both admins yet raises no gap. The completeness gate must RETAIN the
6619        // stored roster rather than persist the lossy one.
6620        let relay2 = MemoryRelay::new();
6621        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
6622        follow_control(&relay2, &community, &session).await.unwrap();
6623        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6624        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
6625    }
6626
6627    #[tokio::test]
6628    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
6629        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
6630        // Vector already gated roles and grants in-fold; metadata, channels and
6631        // the banlist resolved on permission alone, so a client one sweep behind
6632        // honored an edit from an admin whose demotion it had not read yet.
6633        let (_tmp, _guard, owner) = init_test_db();
6634        let relay = MemoryRelay::new();
6635        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
6636        let admin = Keys::generate();
6637        let rid = "a7".repeat(32);
6638        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
6639        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
6640
6641        // The admin acts WITHOUT citing (what every pre-citation client emitted).
6642        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6643        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
6644        let rumor = control::build_edition_rumor(
6645            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
6646            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
6647            &serde_json::to_string(&meta).unwrap(), 1_000, None,
6648        );
6649        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
6650        relay.publish(&wrap, &community.relays).await.unwrap();
6651
6652        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
6653        let victim = Keys::generate().public_key().to_hex();
6654        let ban_rumor = control::build_edition_rumor(
6655            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
6656            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
6657        );
6658        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
6659        relay.publish(&ban_wrap, &community.relays).await.unwrap();
6660
6661        let session = SessionGuard::capture();
6662        let updated = follow_control(&relay, &community, &session).await.unwrap();
6663        assert!(
6664            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
6665            "an uncited metadata edit must not be honored",
6666        );
6667        let authority = fetch_authority(&relay, &community).await;
6668        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
6669        // The positive case (this same admin, citing, lands) is
6670        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
6671        // helper cites, so it proves the gate is the CITATION and not the
6672        // permission. Re-proving it here would need a fresh chain anyway: a
6673        // cited edition chaining onto the rejected one above is gapped, not
6674        // refused.
6675    }
6676
6677    #[tokio::test]
6678    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
6679        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
6680        // owner revokes the grant, the (now unauthorized) admin's further edit drops
6681        // and the name holds at the last authorized state.
6682        let (_tmp, _guard, owner) = init_test_db();
6683        let relay = MemoryRelay::new();
6684        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
6685        let admin = Keys::generate();
6686        let rid = "a1".repeat(32);
6687        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6688        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6689        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
6690
6691        let session = SessionGuard::capture();
6692        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
6693        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
6694
6695        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
6696        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
6697        let _ = follow_control(&relay, &community, &session).await.unwrap();
6698        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6699        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
6700    }
6701
6702    #[tokio::test]
6703    async fn a_roleless_member_cannot_edit_metadata() {
6704        let (_tmp, _guard, _owner) = init_test_db();
6705        let relay = MemoryRelay::new();
6706        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6707        let stranger = Keys::generate();
6708        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
6709        let session = SessionGuard::capture();
6710        assert!(
6711            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6712            "a roleless member's metadata edit never folds"
6713        );
6714    }
6715
6716    #[tokio::test]
6717    async fn a_self_signed_grant_is_not_authority() {
6718        // The self-promotion defense: a member self-signs both a role and a grant of
6719        // it to themselves. authorize_delegation drops both (their signer never traces
6720        // to the owner), so their metadata edit stays unauthorized.
6721        let (_tmp, _guard, _owner) = init_test_db();
6722        let relay = MemoryRelay::new();
6723        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
6724        let rogue = Keys::generate();
6725        let rid = "b2".repeat(32);
6726        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6727        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
6728        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
6729        let session = SessionGuard::capture();
6730        assert!(
6731            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6732            "a self-signed grant confers no authority"
6733        );
6734    }
6735
6736    #[tokio::test]
6737    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
6738        let (_tmp, _guard, owner) = init_test_db();
6739        let relay = MemoryRelay::new();
6740        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
6741        let target = "cc".repeat(32);
6742
6743        // A non-BAN-holder's banlist edition is folded but NOT enforced.
6744        let rogue = Keys::generate();
6745        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
6746        let floors = load_floors(&community);
6747        let editions = fetch_control(&relay, &community).await;
6748        let authority = fold_authority(&community, &editions, &floors);
6749        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
6750
6751        // The owner (supreme, holds BAN) bans the target: now enforced.
6752        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
6753        let editions = fetch_control(&relay, &community).await;
6754        let authority = fold_authority(&community, &editions, &floors);
6755        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
6756    }
6757
6758    #[tokio::test]
6759    async fn a_banned_admin_loses_all_authority() {
6760        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
6761        // banned admin's authority is dropped and their edits refused.
6762        let (_tmp, _guard, owner) = init_test_db();
6763        let relay = MemoryRelay::new();
6764        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
6765        let admin = Keys::generate();
6766        let rid = "e5".repeat(32);
6767        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6768        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6769        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
6770        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
6771
6772        let session = SessionGuard::capture();
6773        assert!(
6774            follow_control(&relay, &community, &session).await.unwrap().is_none(),
6775            "a banned admin's edit is dropped even with an unstripped grant"
6776        );
6777        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6778        assert!(authority.banned.contains(&admin.public_key().to_hex()));
6779        assert!(
6780            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
6781            "a banned admin holds no bit"
6782        );
6783    }
6784
6785    #[tokio::test]
6786    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
6787        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
6788        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
6789        // and NOT the owner (supreme, unbannable).
6790        let (_tmp, _guard, owner) = init_test_db();
6791        let relay = MemoryRelay::new();
6792        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
6793        let admin = Keys::generate();
6794        let moder = Keys::generate();
6795        let stranger = Keys::generate();
6796        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
6797        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 }, 1).await;
6798        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 }, 1).await;
6799        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
6800        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
6801        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
6802
6803        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6804        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
6805        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
6806        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
6807    }
6808
6809    #[tokio::test]
6810    async fn an_unauthorized_higher_banlist_cannot_unban() {
6811        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
6812        // must not erase the owner's ban (author-aware head selection + persisted
6813        // banlist retention).
6814        let (_tmp, _guard, owner) = init_test_db();
6815        let relay = MemoryRelay::new();
6816        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
6817        let target = "cc".repeat(32);
6818        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
6819        let session = SessionGuard::capture();
6820        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
6821
6822        let rogue = Keys::generate();
6823        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
6824        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
6825        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
6826    }
6827
6828    #[tokio::test]
6829    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
6830        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
6831        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
6832        let (_tmp, _guard, _owner) = init_test_db();
6833        let relay = MemoryRelay::new();
6834        let relays = vec!["wss://r".to_string()];
6835        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
6836        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
6837        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
6838
6839        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
6840        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
6841        assert_eq!(rehydrated[0].id().0, community.id().0);
6842        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
6843    }
6844
6845    #[tokio::test]
6846    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
6847        let (_tmp, _guard, _owner) = init_test_db();
6848        let relay = MemoryRelay::new();
6849        let relays = vec!["wss://r".to_string()];
6850        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
6851        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
6852
6853        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
6854        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
6855    }
6856
6857    #[tokio::test]
6858    async fn accepting_the_same_bundle_twice_is_idempotent() {
6859        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
6860        // again must upsert cleanly — same community_id, no duplicate channels, no
6861        // corruption, the keys unchanged.
6862        let (bed, owner, member) = TestBed::new();
6863        bed.swap_to(&owner);
6864        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
6865        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
6866        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6867        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
6868
6869        bed.swap_to(&member);
6870        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
6871        let channels_after_first = first.channels.len();
6872        let root_after_first = first.community_root;
6873
6874        // Accept the identical bundle again (restart / redelivery).
6875        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
6876        assert_eq!(second.id().0, first.id().0, "same community_id");
6877        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
6878        assert_eq!(second.community_root, root_after_first, "root unchanged");
6879
6880        // The persisted state is a single clean community with the expected channels.
6881        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6882        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
6883        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
6884    }
6885
6886    #[tokio::test]
6887    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
6888        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
6889        // severs a member; the owner then unbans + sends a FRESH invite carrying the
6890        // NEW root; the member rejoins at the new epoch and converses again. Proves
6891        // a ban is reversible end-to-end, not a one-way door.
6892        let (bed, owner, member) = TestBed::new();
6893        bed.swap_to(&owner);
6894        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
6895        let general = community.channels[0].id;
6896        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6897        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
6898
6899        bed.swap_to(&member);
6900        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6901        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
6902        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
6903
6904        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
6905        bed.swap_to(&owner);
6906        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6907        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
6908        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
6909        assert_eq!(community.root_epoch, Epoch(1));
6910        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
6911
6912        // The member's follow concludes severance (no blob at the new epoch).
6913        bed.swap_to(&member);
6914        let session = SessionGuard::capture();
6915        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
6916
6917        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
6918        // directly, so the test picks the NEW invite unambiguously rather than an
6919        // arbitrary one of the two pending 3313s).
6920        bed.swap_to(&owner);
6921        set_banlist(&bed.relay, &community, &[]).await.unwrap();
6922        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6923        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
6924        let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
6925
6926        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
6927        bed.swap_to(&member);
6928        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
6929        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
6930        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
6931        let seen = texts_in(&bed.relay, &rejoined, &general).await;
6932        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
6933        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
6934
6935        bed.swap_to(&owner);
6936        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6937        assert!(
6938            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
6939            "the re-admitted member converses again at the new epoch"
6940        );
6941        // And they're back in the memberlist.
6942        let members = memberlist(&bed.relay, &community).await.unwrap();
6943        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
6944    }
6945
6946    #[tokio::test]
6947    async fn dissolution_blocks_a_join() {
6948        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
6949        // refuses to join.
6950        let (bed, owner, member) = TestBed::new();
6951        bed.swap_to(&owner);
6952        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
6953        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6954        let bundle_json = serde_json::to_string(&bundle).unwrap();
6955        dissolve_community(&bed.relay, &community).await.unwrap();
6956        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
6957
6958        bed.swap_to(&member);
6959        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
6960        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
6961    }
6962
6963    #[tokio::test]
6964    async fn only_the_owner_can_dissolve() {
6965        let (bed, owner, member) = TestBed::new();
6966        bed.swap_to(&owner);
6967        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
6968        bed.swap_to(&member);
6969        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
6970        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
6971    }
6972
6973    #[tokio::test]
6974    async fn a_foreign_tombstone_is_not_death() {
6975        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
6976        // owner-gated), so the community is not treated as dead.
6977        let (_tmp, _guard, _owner) = init_test_db();
6978        let relay = MemoryRelay::new();
6979        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
6980        let rogue = Keys::generate();
6981        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
6982        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
6983        relay.publish(&wrap, &community.relays).await.unwrap();
6984        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
6985    }
6986
6987    #[tokio::test]
6988    async fn a_public_channel_reads_history_across_a_refounding() {
6989        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
6990        // pre-rotation messages stay readable (the prior epoch's root is archived and
6991        // the read fans out across held epochs).
6992        let (_tmp, _guard, _owner) = init_test_db();
6993        let relay = MemoryRelay::new();
6994        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
6995        let general = community.channels[0].id;
6996        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
6997
6998        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6999        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7000        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
7001
7002        let texts = texts_in(&relay, &refounded, &general).await;
7003        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
7004        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
7005    }
7006
7007    #[tokio::test]
7008    async fn refounding_aborts_when_control_state_is_withheld() {
7009        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
7010        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
7011        // member at the new epoch a fresh joiner bootstraps).
7012        let (_tmp, _guard, owner) = init_test_db();
7013        let relay = MemoryRelay::new();
7014        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
7015        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
7016        let session = SessionGuard::capture();
7017        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
7018
7019        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
7020        // reloads fresh state) fetches none of the committed heads.
7021        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7022        moved.relays = vec!["wss://empty".into()];
7023        crate::db::community::save_community_v2(&moved).unwrap();
7024
7025        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
7026        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
7027        assert_eq!(
7028            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7029            Epoch(0),
7030            "the epoch did NOT advance (zero published state)"
7031        );
7032    }
7033
7034    #[tokio::test]
7035    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
7036        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
7037        // epoch advances, and the removed member's rekey-follow concludes they're cut.
7038        let (bed, owner, member) = TestBed::new();
7039        bed.swap_to(&owner);
7040        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
7041        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7042        let bundle_json = serde_json::to_string(&bundle).unwrap();
7043        bed.swap_to(&member);
7044        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7045
7046        bed.swap_to(&owner);
7047        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7048        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7049        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
7050        // The owner still reads the compacted control plane at the new epoch.
7051        assert_eq!(
7052            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7053            Epoch(1),
7054            "the owner committed the new epoch"
7055        );
7056
7057        // The removed member, following rekeys, is severed (no blob in the rotation).
7058        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
7059        // swap now bumps the generation exactly like a production swap_session).
7060        bed.swap_to(&member);
7061        let session = SessionGuard::capture();
7062        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7063        assert!(follow.self_removed, "the removed member is cut by the re-founding");
7064    }
7065
7066    #[tokio::test]
7067    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
7068        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
7069        // non-owner admin granted BAN CAN re-found (and every member follows it —
7070        // see the receive-side test), but the "strictly outrank every removed
7071        // target" rule still holds: they can't use it to evict the owner.
7072        let (bed, owner, member) = TestBed::new();
7073        bed.swap_to(&owner);
7074        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
7075        let rid = "b0".repeat(32);
7076        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7077        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7078        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7079        let bundle_json = serde_json::to_string(&bundle).unwrap();
7080        bed.swap_to(&member);
7081        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7082        // Fold the roster so this member's own DB reflects their BAN grant (the
7083        // authority check reads the folded Roster, not the bundle).
7084        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7085        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7086        // Can't evict the owner (no one outranks the owner).
7087        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
7088        // But CAN re-found removing a plain member they outrank (here, nobody).
7089        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
7090    }
7091
7092    #[tokio::test]
7093    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
7094        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
7095        // follow it — owner-only receive silently strands members whose community
7096        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
7097        // BAN", checked against the folded Roster).
7098        let (bed, owner, me) = TestBed::new();
7099        let admin = Keys::generate();
7100        bed.swap_to(&owner);
7101        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
7102        let rid = "b0".repeat(32);
7103        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7104        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7105
7106        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
7107        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7108        let bundle_json = serde_json::to_string(&bundle).unwrap();
7109        bed.swap_to(&me);
7110        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7111        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7112        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7113
7114        // The admin re-founds keeping the owner + me — the owner must always be a
7115        // recipient of a non-owner Refounding.
7116        let new_root = [0xC7; 32];
7117        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7118
7119        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7120            .expect("an authorized admin's Refounding is adopted");
7121        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
7122        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
7123    }
7124
7125    #[tokio::test]
7126    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
7127        // CORD-05 §2: a link shared once keeps working across rotations, because
7128        // its bundle is re-posted behind the same URL. The Refounder can only
7129        // refresh the bundles they hold signer secrets for — their OWN — so
7130        // every other creator has to heal their links when they ADOPT the
7131        // rotation. Without that, an admin's links keep vending the superseded
7132        // root and drop new joiners onto a dead epoch, which is precisely the
7133        // stranding the stable-URL refresh exists to prevent.
7134        let (bed, owner, me) = TestBed::new();
7135        bed.swap_to(&owner);
7136        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
7137        let rid = "b1".repeat(32);
7138        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7139        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7140
7141        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7142        let bundle_json = serde_json::to_string(&bundle).unwrap();
7143        bed.swap_to(&me);
7144        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7145        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7146        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7147
7148        // I mint a link of my own at the CURRENT epoch.
7149        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
7150        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7151        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
7152
7153        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
7154        // its signer secret.
7155        let new_root = [0xD4; 32];
7156        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7157
7158        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7159            .expect("the owner's Refounding is adopted");
7160        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
7161
7162        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7163        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
7164        assert_eq!(
7165            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
7166            new_root,
7167            "and the new root behind the same URL",
7168        );
7169    }
7170
7171    #[tokio::test]
7172    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
7173        // Authority escalation: a BAN-admin can't use a Refounding to evict the
7174        // OWNER (no one outranks the owner). Excluding them makes the rotation
7175        // inadmissible — members fork-reject it rather than migrate to the coup.
7176        let (bed, owner, me) = TestBed::new();
7177        let admin = Keys::generate();
7178        bed.swap_to(&owner);
7179        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
7180        let rid = "b0".repeat(32);
7181        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7182        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7183
7184        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7185        let bundle_json = serde_json::to_string(&bundle).unwrap();
7186        bed.swap_to(&me);
7187        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7188        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7189        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7190
7191        // The admin re-founds delivering to me but NOT the owner — a takeover.
7192        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
7193        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7194        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
7195    }
7196
7197    #[tokio::test]
7198    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
7199        // Authority escalation: two equal-rank BAN-admins — neither strictly
7200        // outranks the other, so one can't Refound the other out. Excluding a
7201        // peer makes the rotation inadmissible.
7202        let (bed, owner, me) = TestBed::new();
7203        let admin_a = Keys::generate();
7204        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
7205        bed.swap_to(&owner);
7206        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
7207        let rid = "b0".repeat(32);
7208        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7209        // Both A and B hold the SAME role (same position 1) → peers.
7210        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
7211        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
7212
7213        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7214        let bundle_json = serde_json::to_string(&bundle).unwrap();
7215        bed.swap_to(&me);
7216        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7217        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7218        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7219
7220        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
7221        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
7222
7223        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7224        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
7225    }
7226
7227    #[tokio::test]
7228    async fn a_retried_refounding_reuses_the_same_root() {
7229        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
7230        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
7231        let (_tmp, _guard, _owner) = init_test_db();
7232        let relay = MemoryRelay::new();
7233        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
7234        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7235        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
7236        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
7237        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
7238    }
7239
7240    #[tokio::test]
7241    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
7242        // CORD-04 §2 rank inversion. Minting at a position you outrank is
7243        // necessary but NOT sufficient: an edition replaces the entity, so a
7244        // gate that only reads the NEW position lets an admin at position 5
7245        // rewrite the position-1 role to position 9. Every check passes (9 is
7246        // beneath them), and the role that outranked them — plus everyone
7247        // holding it — is now beneath them.
7248        let (bed, owner, attacker) = TestBed::new();
7249        bed.swap_to(&owner);
7250        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
7251
7252        // A senior role at position 1, and a mid role at position 5 the attacker holds.
7253        let senior = "a1".repeat(32);
7254        let mid = "a5".repeat(32);
7255        publish_role(&bed.relay, &community, &owner.keys,
7256            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
7257        publish_role(&bed.relay, &community, &owner.keys,
7258            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
7259        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
7260
7261        // The attacker republishes the SENIOR role, dropping it beneath themselves.
7262        publish_role(&bed.relay, &community, &attacker.keys,
7263            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
7264
7265        let authority = fetch_authority(&bed.relay, &community).await;
7266        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
7267        assert_eq!(
7268            folded_senior.position, 1,
7269            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
7270        );
7271    }
7272
7273    #[tokio::test]
7274    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
7275        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
7276        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
7277        // an uncited Vector admin's ban/role/channel edit was silently dropped
7278        // by every Armada client — only the owner's actions crossed. The
7279        // citation must name the actor's OWN grant coordinate, at the version
7280        // and edition hash the verifier can match against a grant it holds.
7281        let (bed, owner, admin) = TestBed::new();
7282        bed.swap_to(&owner);
7283        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
7284        let rid = "c1".repeat(32);
7285        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
7286        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
7287
7288        // The owner's own edition carries NO citation: their rank is the id.
7289        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
7290        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
7291        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
7292            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
7293            .max_by_key(|e| e.version).expect("the owner's metadata edition");
7294        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
7295
7296        // The admin JOINS and folds — the citation names the grant head their own
7297        // client has actually synced, so the fold must have persisted it.
7298        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7299        let bundle_json = serde_json::to_string(&bundle).unwrap();
7300        bed.swap_to(&admin);
7301        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7302        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7303        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7304        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
7305
7306        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
7307            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
7308            .expect("the admin's banlist edition");
7309        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
7310        assert_eq!(
7311            cite.entity_id,
7312            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
7313            "the citation must name the ACTOR'S OWN grant coordinate",
7314        );
7315        assert!(cite.version >= 1, "pinned to a real grant version");
7316    }
7317
7318    #[tokio::test]
7319    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
7320        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
7321        // fold is a boundary like any other: MANAGE_METADATA makes an editor
7322        // authorized, not trusted. An oversize list costs every member a fan-out
7323        // per publish and the slowest of N per fetch — and Armada caps at 5, so
7324        // an uncapped fold also splits the two clients' operative sets.
7325        let (_tmp, _guard, _owner) = init_test_db();
7326        let relay = MemoryRelay::new();
7327        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
7328
7329        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
7330        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
7331        edit_community_metadata(&relay, &community, &meta).await.unwrap();
7332
7333        let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
7334            .expect("the metadata edition is folded");
7335        assert_eq!(
7336            updated.relays.len(),
7337            crate::community::MAX_COMMUNITY_RELAYS,
7338            "a folded relay list must be truncated, never adopted whole",
7339        );
7340
7341        // …and the fold must SETTLE: comparing an oversize edition against the
7342        // capped working set would never be equal, so every later fold would
7343        // report a change and re-save forever.
7344        let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
7345        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
7346    }
7347
7348    #[tokio::test]
7349    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
7350        // One Invite List spans every community, so "I hold links" must never be
7351        // read as "I hold links HERE". A member with links elsewhere adopting a
7352        // rotation would otherwise publish an empty Registry edition into this
7353        // community — a control-plane write and a version bump on a coordinate
7354        // they never owned, every rotation, forever.
7355        let (bed, owner, me) = TestBed::new();
7356        bed.swap_to(&owner);
7357        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
7358        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
7359        let rid = "b2".repeat(32);
7360        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7361        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7362
7363        let bundle = bundle_of(&host, Some(owner.keys.public_key()), None, None);
7364        let bundle_json = serde_json::to_string(&bundle).unwrap();
7365        bed.swap_to(&me);
7366        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7367        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7368        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7369
7370        // My only link lives in a DIFFERENT community.
7371        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
7372
7373        let before = bed.relay.stored_count();
7374        let new_root = [0xE1; 32];
7375        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7376        let rotation_events = bed.relay.stored_count() - before;
7377
7378        let after_adopt = bed.relay.stored_count();
7379        follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
7380        assert_eq!(
7381            bed.relay.stored_count(),
7382            after_adopt,
7383            "adopting a rotation must publish NOTHING when I minted no links here",
7384        );
7385        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
7386    }
7387
7388    #[tokio::test]
7389    async fn an_expired_link_stops_keeping_the_community_public() {
7390        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
7391        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
7392        // the Registry states a door that isn't there — the aggregate never
7393        // empties and the community reads Public forever, silently inverting
7394        // every gate that hangs off that reading.
7395        let (_tmp, _guard, _owner) = init_test_db();
7396        let relay = MemoryRelay::new();
7397        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
7398
7399        // A link that lapsed a minute ago.
7400        let past = now_ms() - 60_000;
7401        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
7402        assert!(
7403            !community_is_public(&relay, &community).await,
7404            "an already-expired link must never read as a live door",
7405        );
7406
7407        // …and one that hasn't, to prove the filter isn't just dropping everything.
7408        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
7409        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
7410    }
7411
7412    #[tokio::test]
7413    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
7414        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
7415        // link publishes it (Public); retiring the last link empties it (Private).
7416        let (_tmp, _guard, _owner) = init_test_db();
7417        let relay = MemoryRelay::new();
7418        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
7419        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
7420
7421        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
7422        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
7423        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
7424        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
7425
7426        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
7427        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
7428        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
7429        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
7430        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
7431    }
7432
7433    #[tokio::test]
7434    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
7435        // Registries are coordinate-bound to their creator, but `fold_head` picks an
7436        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
7437        // that freely by varying content. Folding before authorising would let any
7438        // member occupy the owner's registry head, fail the authority check, and drop
7439        // the whole registry: a live invite link silently retired, flipping the
7440        // community to Private and steering a moderator into the wrong ban remedy.
7441        let (_tmp, _guard, owner) = init_test_db();
7442        let relay = MemoryRelay::new();
7443        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
7444        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
7445        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
7446
7447        let cid = community.id();
7448        let control = control_group_key(&community.community_root, cid, community.root_epoch);
7449        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
7450
7451        let query = Query {
7452            kinds: vec![stream::KIND_WRAP],
7453            authors: vec![control.pk_hex()],
7454            limit: Some(FOLLOW_PAGE),
7455            ..Default::default()
7456        };
7457        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
7458        let target = wraps
7459            .iter()
7460            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
7461            .filter(|e| e.entity_id == eid)
7462            .max_by_key(|e| e.version)
7463            .expect("the owner published a registry");
7464
7465        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
7466        // real head on the tiebreak (~2 tries against a uniform id).
7467        let rogue = Keys::generate();
7468        let mut planted = false;
7469        for n in 0..4_000u64 {
7470            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
7471            let rumor = control::build_edition_rumor(
7472                rogue.public_key(),
7473                vsk::INVITE_LINKS,
7474                &eid,
7475                target.version,
7476                target.prev_hash.as_ref(),
7477                &content,
7478                9_000,
7479                None,
7480            );
7481            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
7482            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
7483            if ed.inner_id < target.inner_id {
7484                relay.publish(&w, &community.relays).await.unwrap();
7485                planted = true;
7486                break;
7487            }
7488        }
7489        assert!(planted, "the test needs a fork that wins the tiebreak");
7490
7491        assert!(
7492            community_is_public(&relay, &community).await,
7493            "an unauthorised fork must not retire the owner's live link"
7494        );
7495    }
7496
7497    #[tokio::test]
7498    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
7499        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
7500        let (_tmp, _guard, owner) = init_test_db();
7501        let relay = MemoryRelay::new();
7502        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
7503        let rogue = Keys::generate();
7504        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
7505        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
7506        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
7507        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7508        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
7509        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
7510        relay.publish(&wrap, &community.relays).await.unwrap();
7511        let _ = owner;
7512        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
7513    }
7514
7515    #[tokio::test]
7516    async fn full_lifecycle_e2e() {
7517        // The whole stack end to end across two accounts: create -> Public link ->
7518        // owner grants an admin -> member joins + reads history -> admin edits metadata
7519        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
7520        // Refounding) -> the banned member is severed AND stays banned across the new
7521        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
7522        let (bed, owner, member) = TestBed::new();
7523
7524        bed.swap_to(&owner);
7525        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
7526        let general = community.channels[0].id;
7527        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
7528
7529        // Public link → the community reads Public.
7530        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
7531        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
7532
7533        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
7534        let rid = "aa".repeat(32);
7535        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7536        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7537
7538        // Member joins from the bundle + reads the owner's message.
7539        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7540        let bundle_json = serde_json::to_string(&bundle).unwrap();
7541        bed.swap_to(&member);
7542        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7543        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
7544        // The admin renames the community.
7545        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
7546
7547        // Owner follows: the admin's rename folds (authorized).
7548        bed.swap_to(&owner);
7549        let session = SessionGuard::capture();
7550        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
7551        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
7552
7553        // Ban the member (the three-removal composition, in order).
7554        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
7555        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
7556        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
7557        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
7558        // The ban survives the Refounding (the banlist head compacted forward).
7559        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7560        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
7561        // Pre-ban history still reads across the new epoch.
7562        assert!(
7563            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
7564            "pre-refounding history stays readable"
7565        );
7566
7567        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
7568        // the swap (the harness swap bumps the generation like production).
7569        bed.swap_to(&member);
7570        let session = SessionGuard::capture();
7571        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7572        assert!(follow.self_removed, "the banned member is cryptographically cut");
7573
7574        // Owner dissolves → sealed.
7575        bed.swap_to(&owner);
7576        dissolve_community(&bed.relay, &refounded).await.unwrap();
7577        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
7578    }
7579
7580    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
7581    /// over one shared relay, create → channels (public + private) → converse both ways →
7582    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
7583    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
7584    /// doubles as a reference transcript and a re-runnable regression.
7585    #[tokio::test]
7586    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
7587        // A member forges a higher-version role edition at the admin coordinate before a
7588        // refounding. The compaction must carry the AUTHORIZED floor head, not the
7589        // author-blind version tip — else the forgery is re-anchored, honest folders drop
7590        // it, and the admin role vanishes at the new epoch (silent suppression).
7591        let (bed, owner, member) = TestBed::new();
7592        let attacker = Keys::generate();
7593        bed.swap_to(&owner);
7594        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
7595        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
7596        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7597        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
7598        // Owner folds → the authorized role/grant heads are floored.
7599        let session = SessionGuard::capture();
7600        follow_control(&bed.relay, &community, &session).await.unwrap();
7601        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
7602
7603        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
7604        publish_role(&bed.relay, &community, &attacker, &Role { role_id: rid.clone(), name: "pwn".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 }, 2).await;
7605
7606        // Owner refounds (keeping everyone).
7607        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
7608        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
7609
7610        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
7611        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7612        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
7613    }
7614
7615    #[tokio::test]
7616    async fn memberlist_survives_a_refounding_via_the_snapshot() {
7617        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
7618        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
7619        let (bed, owner, member) = TestBed::new();
7620        bed.swap_to(&owner);
7621        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
7622
7623        // Member joins (a Guestbook Join at epoch 0).
7624        let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
7625        bed.swap_to(&member);
7626        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7627        bed.swap_to(&owner);
7628        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
7629
7630        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
7631        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
7632        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
7633
7634        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
7635        let members = memberlist(&bed.relay, &refounded).await.unwrap();
7636        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
7637        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
7638    }
7639
7640    #[tokio::test]
7641    async fn e2e_two_accounts_channels_converse_moderate() {
7642        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
7643        use nostr_sdk::prelude::ToBech32;
7644        let (bed, a, b) = TestBed::new();
7645        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
7646        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
7647        println!("\n===== Concord v2 deep e2e =====");
7648        println!("[acct] A (owner)  = {a_npub}");
7649        println!("[acct] B (member) = {b_npub}");
7650
7651        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
7652        bed.swap_to(&a);
7653        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
7654        let general = community.channels[0].id;
7655        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
7656
7657        // A PRIVATE channel via the REAL create path: an independent key minted at
7658        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
7659        // then announced (vsk 2) — later carried to B in the join bundle.
7660        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
7661        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7662        let priv_ch = community.channel(&priv_id).unwrap();
7663        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
7664        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
7665
7666        // Two more PUBLIC channels via the real create path.
7667        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
7668        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7669        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
7670        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7671        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
7672        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
7673
7674        // A talks in a few channels.
7675        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
7676        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
7677        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
7678        println!("[msg] A posted in #general / #announcements / #mods");
7679
7680        // ── A grants B admin, mints a public link, B joins from the bundle ──
7681        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
7682        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
7683        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
7684        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7685        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
7686        println!("[invite] granted B @admin · minted link {}", link.url);
7687
7688        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
7689        bed.swap_to(&b);
7690        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7691        println!("[join] B joined; sees {} channels", b_view.channels.len());
7692        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
7693        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");
7694        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
7695        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");
7696        // B folds the control plane (persisting the roster) — the live worker does
7697        // this right after any join; B's admin standing gates B's channel ops below.
7698        let session_b = SessionGuard::capture();
7699        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
7700            b_view = fresh;
7701        }
7702        println!("[follow] B folded control (roster persisted: B is @admin)");
7703
7704        // ── Conversation both ways + persistence (get_messages-level) ──
7705        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
7706        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
7707        println!("[msg] B replied in #general + #mods");
7708        // Persist B's own #general view into the shared store (what sync/live ingest does)
7709        // and confirm it reads back via STATE — get_messages parity.
7710        let my_pk = b.keys.public_key();
7711        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
7712        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
7713            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
7714            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
7715        }
7716        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
7717        println!("[persist] #general history persisted into the shared events store");
7718
7719        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
7720        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
7721        bed.swap_to(&a);
7722        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
7723        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
7724        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
7725        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
7726        println!("[ops] reaction + edit + delete round-tripped");
7727
7728        // ── B creates a channel as admin, A folds it in ──
7729        bed.swap_to(&b);
7730        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
7731        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
7732        bed.swap_to(&a);
7733        let session = SessionGuard::capture();
7734        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
7735            community = updated;
7736        }
7737        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
7738        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
7739
7740        // ── A creates a SECOND private channel while B is already a member: B is a
7741        // recipient of the creation delivery, so B keys up from the rekey plane
7742        // (keyless record → cursor walk → blob) with no bundle involved ──
7743        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
7744        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7745        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
7746        println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
7747        bed.swap_to(&b);
7748        let session_b2 = SessionGuard::capture();
7749        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
7750            b_view = fresh;
7751        }
7752        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
7753        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
7754        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
7755        b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
7756        let ch = b_view.channel(&vault).expect("still recorded");
7757        assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
7758        assert!(
7759            texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
7760            "B reads the private history with the ADOPTED key"
7761        );
7762        send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
7763        bed.swap_to(&a);
7764        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7765        assert!(
7766            texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
7767            "A reads B's reply on the natively-created private channel"
7768        );
7769        println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
7770
7771        // ── Members ──
7772        let members = memberlist(&bed.relay, &community).await.unwrap();
7773        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
7774        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
7775        println!("[members] {} members: A + B present", members.len());
7776
7777        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
7778        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
7779        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
7780        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
7781        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
7782        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7783        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
7784        assert!(texts_in(&bed.relay, &refounded, &general).await.iter().any(|t| t == "A: welcome to the deep e2e"), "pre-ban history reads across the new epoch");
7785        assert!(
7786            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
7787            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
7788        );
7789        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
7790        // B concludes it's severed.
7791        bed.swap_to(&b);
7792        let session_b3 = SessionGuard::capture();
7793        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
7794        println!("[ban] B's rekey-follow: self_removed = true (severed)");
7795
7796        // ── Unban: A lifts the ban ──
7797        bed.swap_to(&a);
7798        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
7799        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
7800        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
7801        println!("[unban] B removed from the banlist (re-invitable)");
7802
7803        // ── Dissolve ──
7804        dissolve_community(&bed.relay, &refounded).await.unwrap();
7805        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
7806        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
7807    }
7808
7809    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
7810    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
7811    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
7812    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
7813    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
7814    #[tokio::test]
7815    #[ignore]
7816    async fn live_e2e_two_accounts() {
7817        use crate::community::transport::LiveTransport;
7818        use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
7819
7820        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7821        let relays = vec![relay.clone()];
7822        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7823        crate::db::close_database();
7824        crate::db::clear_id_caches();
7825        let tmp = tempfile::tempdir().unwrap();
7826        crate::db::set_app_data_dir(tmp.path().to_path_buf());
7827
7828        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
7829        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
7830        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
7831
7832        let log = |line: String| {
7833            println!("{line}");
7834            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
7835                use std::io::Write;
7836                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
7837                    let _ = writeln!(f, "{line}");
7838                }
7839            }
7840        };
7841        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
7842        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
7843        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
7844
7845        for k in [&a, &b] {
7846            let npub = k.public_key().to_bech32().unwrap();
7847            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7848            crate::db::set_current_account(npub.clone()).unwrap();
7849            crate::db::init_database(&npub).unwrap();
7850        }
7851        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
7852        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
7853        let client = crate::nostr_client_builder().build();
7854        client.add_managed_relay(relay.as_str()).await.ok();
7855        client.connect().await;
7856        crate::state::set_nostr_client(client);
7857        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7858        let become_acct = |k: &Keys| {
7859            let npub = k.public_key().to_bech32().unwrap();
7860            crate::db::set_current_account(npub.clone()).unwrap();
7861            crate::db::init_database(&npub).unwrap();
7862            crate::db::clear_id_caches();
7863            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
7864            crate::state::set_my_public_key(k.public_key());
7865        };
7866        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
7867
7868        // A: create + a channel + grant B admin + mint link.
7869        become_acct(&a);
7870        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
7871        let general = community.channels[0].id;
7872        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
7873        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
7874        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
7875        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7876        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
7877        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
7878        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
7879        log(format!("[invite] B granted @admin · link {}", link.url));
7880        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
7881        settle().await;
7882
7883        // B: join + read A's history + reply.
7884        become_acct(&b);
7885        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
7886        log(format!("[join] B joined; {} channels", b_view.channels.len()));
7887        settle().await;
7888        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
7889        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
7890        log(format!("[read] B sees #general: {seen:?}"));
7891        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
7892        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
7893
7894        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
7895        // sends) directly onto the chat plane — proving the cross-client thread
7896        // RECEIVE path works live, not just in the offline fixture.
7897        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
7898        let hello_id = hello.event.opened().rumor_id.to_hex();
7899        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7900        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
7901        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());
7902        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
7903        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
7904        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
7905        settle().await;
7906
7907        // A reads the thread reply back, rendered inline with A's message as parent.
7908        become_acct(&a);
7909        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7910        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
7911        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
7912        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
7913            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
7914            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");
7915        } else {
7916            panic!("the 1111 parsed as a Message");
7917        }
7918        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
7919        become_acct(&b);
7920        settle().await;
7921
7922        // A: create a PRIVATE channel while B is already a member — B is a recipient
7923        // of the creation delivery, so B keys up from the rekey plane over the real
7924        // relay (no bundle involved), then the two converse on it.
7925        become_acct(&a);
7926        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7927        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
7928        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7929        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
7930        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
7931        settle().await;
7932
7933        become_acct(&b);
7934        let session_b = SessionGuard::capture();
7935        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
7936        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
7937            b_view = fresh;
7938        }
7939        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
7940            b_view = fresh;
7941        }
7942        let vch = b_view.channel(&vault).expect("B folded the vault");
7943        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
7944        let vseen = texts_in(&transport, &b_view, &vault).await;
7945        log(format!("[read] B sees #vault: {vseen:?}"));
7946        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
7947        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
7948        settle().await;
7949
7950        become_acct(&a);
7951        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7952        assert!(
7953            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
7954            "A reads B's private reply"
7955        );
7956        log("[private] two-way #vault conversation over the live relay".to_string());
7957
7958        // A: ban B (three-removal) + dissolve.
7959        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
7960        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
7961        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
7962        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
7963        settle().await;
7964        dissolve_community(&transport, &refounded).await.expect("dissolve");
7965        log("[dissolve] community sealed".to_string());
7966        log("===== LIVE e2e PASS =====".to_string());
7967    }
7968
7969    #[tokio::test]
7970    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
7971        // The tombstone rides its own public plane, watched live — an OFFLINE
7972        // member's catch-up must fetch it too, or they follow (and post into) a
7973        // grave forever.
7974        let (bed, owner, member) = TestBed::new();
7975        bed.swap_to(&owner);
7976        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7977        let general = community.channels[0].id;
7978        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7979
7980        bed.swap_to(&member);
7981        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7982        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7983
7984        // The owner dissolves while the member sleeps.
7985        bed.swap_to(&owner);
7986        dissolve_community(&bed.relay, &community).await.unwrap();
7987
7988        // The member's catch-up learns of the death, seals, and refuses to post.
7989        bed.swap_to(&member);
7990        let session = SessionGuard::capture();
7991        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7992        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
7993        assert!(!follow.self_removed && follow.updated.is_none());
7994        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7995        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
7996        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
7997        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
7998        // Subsequent follows take the local fast path — still dissolved, no churn.
7999        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8000        assert!(again.dissolved && again.updated.is_none());
8001    }
8002
8003    #[tokio::test]
8004    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
8005        // Scale stress: MANY private channels, each rotated on every Refounding.
8006        // A member offline across two refoundings must converge on all of them
8007        // (the per-channel rotation fan in refound + the follow's channel×root×step
8008        // loops stay bounded) with every channel's history readable.
8009        const PRIV_CHANNELS: usize = 6;
8010        let (bed, owner, member) = TestBed::new();
8011        bed.swap_to(&owner);
8012        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
8013        let mut priv_ids = Vec::new();
8014        for i in 0..PRIV_CHANNELS {
8015            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
8016            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8017            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
8018            priv_ids.push(id);
8019        }
8020        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
8021
8022        // Member joins at epoch 0 with all channel keys, then goes offline.
8023        bed.swap_to(&member);
8024        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8025        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
8026
8027        // Two refoundings (each rotates the base + every private channel).
8028        bed.swap_to(&owner);
8029        for epoch in 1..=2u64 {
8030            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8031            assert_eq!(community.root_epoch, Epoch(epoch));
8032            for id in &priv_ids {
8033                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
8034            }
8035        }
8036
8037        // Member returns: bounded follow to quiescence.
8038        bed.swap_to(&member);
8039        let session = SessionGuard::capture();
8040        let mut passes = 0;
8041        loop {
8042            passes += 1;
8043            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
8044            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8045            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8046            assert!(!rk.self_removed);
8047            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8048            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
8049            if rk.updated.is_none() && ctl.is_none() {
8050                break;
8051            }
8052        }
8053        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8054        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
8055        // Every private channel converged to the owner's current key + reads all epochs.
8056        for id in &priv_ids {
8057            let mine = caught_up.channel(id).expect("channel survived");
8058            let theirs = community.channel(id).unwrap();
8059            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
8060            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
8061            let texts = texts_in(&bed.relay, &caught_up, id).await;
8062            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
8063            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
8064            for epoch in 1..=2u64 {
8065                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
8066            }
8067        }
8068    }
8069
8070    #[tokio::test]
8071    async fn an_offline_member_catches_up_across_three_refoundings() {
8072        // The deep offline-online scenario: a member sleeps through THREE
8073        // Refoundings, per-refound private-channel rotations, a mid-life private
8074        // channel CREATED while they slept, a public channel, a rename, and a
8075        // ban — then returns and converges by follow alone (no rejoin).
8076        use nostr_sdk::prelude::ToBech32;
8077        let (bed, owner, member) = TestBed::new();
8078        bed.swap_to(&owner);
8079        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
8080        let general = community.channels[0].id;
8081        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8082        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8083        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
8084        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
8085        let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
8086
8087        // Member joins at epoch 0, then goes OFFLINE.
8088        bed.swap_to(&member);
8089        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8090        assert_eq!(member_view.root_epoch, Epoch(0));
8091
8092        // While they sleep, the owner reshapes everything across three epochs.
8093        bed.swap_to(&owner);
8094        let stranger = Keys::generate();
8095        for epoch in 1..=3u64 {
8096            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8097            assert_eq!(community.root_epoch, Epoch(epoch));
8098            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
8099            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
8100        }
8101        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
8102        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8103        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8104        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8105        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
8106        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
8107        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
8108        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8109
8110        // The member RETURNS: rekey+control follow to quiescence (the worker's
8111        // loop, driven explicitly). Bounded — convergence must be fast.
8112        bed.swap_to(&member);
8113        let session = SessionGuard::capture();
8114        let mut passes = 0;
8115        loop {
8116            passes += 1;
8117            assert!(passes <= 6, "catch-up must converge, not churn");
8118            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8119            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8120            assert!(!rekeyed.self_removed, "the member was never removed");
8121            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8122            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
8123            if rekeyed.updated.is_none() && controlled.is_none() {
8124                break;
8125            }
8126        }
8127        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8128
8129        // Base + name converged.
8130        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
8131        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
8132        assert_eq!(caught_up.name, "Sleeper Reborn");
8133        // Channels: renamed set incl. the mid-sleep public + private ones.
8134        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
8135        let m = caught_up.channel(&mods).expect("mods survived");
8136        let owner_mods = community.channel(&mods).unwrap();
8137        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
8138        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
8139        let v = caught_up.channel(&vault).expect("vault folded in");
8140        assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
8141        // Banlist survived the compactions.
8142        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
8143        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
8144        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
8145        // History reads across EVERY epoch (public via base-root archive, private
8146        // via the per-channel archive built during the walk).
8147        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
8148        for epoch in 0..=3u64 {
8149            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
8150            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
8151        }
8152        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
8153        for epoch in 0..=3u64 {
8154            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
8155            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
8156        }
8157        assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
8158        // And the member can still speak.
8159        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
8160        bed.swap_to(&owner);
8161        assert!(
8162            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
8163            "the caught-up member converses at the new epoch ({})",
8164            member.keys.public_key().to_bech32().unwrap()
8165        );
8166    }
8167
8168    /// Seal `n` messages onto a community's #general, one per second starting at
8169    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
8170    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
8171        let general = community.channels[0].id;
8172        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
8173        for i in 0..n {
8174            let at = base_secs + i as u64;
8175            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
8176            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
8177            relay.publish(&wrap, &community.relays).await.unwrap();
8178        }
8179    }
8180
8181    #[tokio::test]
8182    async fn the_history_walk_pages_past_a_multi_page_burst() {
8183        // A bot offline through 120 messages must catch ALL of them, not the
8184        // newest page — the v1 sync-gap class, closed by until-paging.
8185        let (_tmp, _guard, owner) = init_test_db();
8186        let relay = MemoryRelay::new();
8187        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
8188        let general = community.channels[0].id;
8189        flood_general(&relay, &community, &owner, 120, 10_000).await;
8190
8191        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
8192        assert_eq!(all.len(), 120, "the walk pages the whole burst");
8193        // Oldest→newest, no duplicates.
8194        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
8195        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
8196        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
8197        let unique: std::collections::HashSet<&String> = contents.iter().collect();
8198        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
8199
8200        // The single-page fetch stays a single page.
8201        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
8202        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
8203        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
8204    }
8205
8206    #[tokio::test]
8207    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
8208        let (_tmp, _guard, owner) = init_test_db();
8209        let relay = MemoryRelay::new();
8210        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
8211        let general = community.channels[0].id;
8212        flood_general(&relay, &community, &owner, 120, 10_000).await;
8213
8214        // The caller says "I hold everything" after the first page — no deeper fetch.
8215        let mut pages = 0usize;
8216        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, crate::community::transport::Evidence::Quorum, |_| {
8217            pages += 1;
8218            false
8219        })
8220        .await
8221        .unwrap();
8222        assert_eq!(pages, 1, "the early stop is consulted once");
8223        assert_eq!(got.len(), 50, "only the newest page is fetched");
8224        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
8225    }
8226
8227    #[tokio::test]
8228    async fn a_same_second_history_wall_terminates_instead_of_looping() {
8229        // 60 messages in ONE second with a 25-wrap page: a second-granular
8230        // `until` can never page past the wall — the walk must step over it
8231        // (bounded loss, logged) rather than spin.
8232        let (_tmp, _guard, owner) = init_test_db();
8233        let relay = MemoryRelay::new();
8234        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
8235        let general = community.channels[0].id;
8236        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
8237        for i in 0..60usize {
8238            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
8239            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
8240            relay.publish(&wrap, &community.relays).await.unwrap();
8241        }
8242        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
8243        assert!(got.len() >= 25, "at least the relay page is read");
8244        assert!(got.len() <= 60, "sane bound");
8245        // Termination is the assertion: reaching here means the wall didn't loop.
8246    }
8247
8248    #[tokio::test]
8249    async fn a_grant_revoke_survives_a_withholding_relay() {
8250        // Floor persistence on the delegation plane: after the owner revokes an admin,
8251        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
8252        let (_tmp, _guard, owner) = init_test_db();
8253        let relay = MemoryRelay::new();
8254        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
8255        let admin = Keys::generate();
8256        let rid = "d4".repeat(32);
8257        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8258        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8259        let session = SessionGuard::capture();
8260        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
8261        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
8262        follow_control(&relay, &community, &session).await.unwrap();
8263
8264        // A stale relay serves only the grant prefix (v1, the live grant).
8265        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8266        let mut stale = community.clone();
8267        stale.relays = vec!["wss://stale".into()];
8268        let floors = load_floors(&community);
8269        let editions = fetch_control(&relay, &stale).await;
8270        let authority = fold_authority(&stale, &editions, &floors);
8271        assert!(
8272            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
8273            "the persisted grant floor refuses the rolled-back (re-granted) view"
8274        );
8275    }
8276
8277    /// Load the current-epoch floors for a community (test mirror of follow_control).
8278    fn load_floors(community: &CommunityV2) -> Floors {
8279        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8280        crate::db::community::get_all_edition_heads_full(&cid_hex)
8281            .unwrap_or_default()
8282            .into_iter()
8283            .filter(|(_, f)| f.0 == community.root_epoch.0)
8284            .map(|(e, f)| (e, (f.1, f.2, f.3)))
8285            .collect()
8286    }
8287
8288    /// Fetch + open every control edition at a community's control plane (test helper).
8289    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
8290        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8291        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8292        relay
8293            .fetch(&q, &community.relays)
8294            .await
8295            .unwrap_or_default()
8296            .iter()
8297            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
8298            .collect()
8299    }
8300
8301    #[tokio::test]
8302    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
8303        let (_tmp, _guard, _owner) = init_test_db();
8304        let relay = MemoryRelay::new();
8305        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
8306        let session = SessionGuard::capture();
8307        // Only the genesis editions exist; folding them reproduces the held view.
8308        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
8309    }
8310
8311    #[tokio::test]
8312    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
8313        let (_tmp, _guard, owner) = init_test_db();
8314        let relay = MemoryRelay::new();
8315        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
8316        let new_id = ChannelId([0x5a; 32]);
8317        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
8318
8319        let session = SessionGuard::capture();
8320        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
8321        assert_eq!(updated.channels.len(), 2);
8322        let added = updated.channel(&new_id).expect("the new channel folded in");
8323        assert_eq!(added.name, "announcements");
8324        assert!(!added.private);
8325        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
8326
8327        // The new channel is now in the realtime author-set (it would be subscribed).
8328        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
8329        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
8330        assert!(authors.contains(&addr), "the added channel joins the live subscription");
8331
8332        // Persisted: a reload sees it too.
8333        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8334        assert!(reloaded.channel(&new_id).is_some());
8335    }
8336
8337    #[tokio::test]
8338    async fn follow_control_renames_the_community_and_an_existing_channel() {
8339        let (_tmp, _guard, owner) = init_test_db();
8340        let relay = MemoryRelay::new();
8341        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
8342        let general = community.channels[0].id;
8343        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
8344        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
8345        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
8346
8347        let session = SessionGuard::capture();
8348        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
8349        assert_eq!(updated.name, "New Name");
8350        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
8351        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
8352    }
8353
8354    #[tokio::test]
8355    async fn follow_control_deletes_a_channel() {
8356        let (_tmp, _guard, owner) = init_test_db();
8357        let relay = MemoryRelay::new();
8358        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
8359        let extra = ChannelId([0x77; 32]);
8360        let session = SessionGuard::capture();
8361
8362        // The channel is first added and folded into the held view.
8363        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
8364        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
8365        assert!(with_extra.channel(&extra).is_some());
8366
8367        // Then it's tombstoned — the delete (higher version) folds the held one back out.
8368        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
8369        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
8370        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
8371        assert_eq!(updated.channels.len(), 1, "only #general remains");
8372    }
8373
8374    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
8375    /// community's control plane onto a second relay URL — the withholding-relay
8376    /// simulation: everything it serves is genuinely owner-signed, just stale.
8377    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
8378        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8379        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8380        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8381        for w in &wraps {
8382            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
8383                if ed.version <= max_version {
8384                    relay.inject(w, &[stale_relay.to_string()]);
8385                }
8386            }
8387        }
8388    }
8389
8390    #[tokio::test]
8391    async fn a_withholding_relay_cannot_roll_back_a_rename() {
8392        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
8393        // only the (owner-signed) v1 genesis must not revert the held name.
8394        let (_tmp, _guard, owner) = init_test_db();
8395        let relay = MemoryRelay::new();
8396        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
8397        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
8398
8399        let session = SessionGuard::capture();
8400        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
8401        assert_eq!(updated.name, "Renamed");
8402
8403        // The stale relay holds only the genesis prefix; point the follow at it.
8404        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8405        let mut stale_view = updated.clone();
8406        stale_view.relays = vec!["wss://stale".into()];
8407        assert!(
8408            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8409            "a stale-only relay must not change the held view"
8410        );
8411        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8412        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
8413    }
8414
8415    #[tokio::test]
8416    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
8417        let (_tmp, _guard, owner) = init_test_db();
8418        let relay = MemoryRelay::new();
8419        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
8420        let extra = ChannelId([0x44; 32]);
8421        let session = SessionGuard::capture();
8422
8423        // A same-content metadata edit: no visible change (None), but the floor must
8424        // still advance to v2 (so the genesis metadata can't re-present below).
8425        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
8426        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
8427
8428        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
8429        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
8430        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
8431        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
8432        assert!(pruned.channel(&extra).is_none());
8433
8434        // The stale relay serves the add (v1) but withholds the delete (v2).
8435        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
8436        let mut stale_view = pruned.clone();
8437        stale_view.relays = vec!["wss://stale".into()];
8438        assert!(
8439            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8440            "the withheld delete must not resurrect the channel"
8441        );
8442        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8443        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
8444    }
8445
8446    #[tokio::test]
8447    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
8448        // The Armada-convergence carve-out: a Refounding compacts the chain and
8449        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
8450        // floor must not block it — epoch-filtering makes the entity bootstrap.
8451        let (_tmp, _guard, owner) = init_test_db();
8452        let relay = MemoryRelay::new();
8453        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
8454        let session = SessionGuard::capture();
8455        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
8456        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
8457        assert_eq!(updated.name, "Edited");
8458
8459        // Refounding lands (epoch bump saved by the rekey path); the compacted head
8460        // arrives DETACHED (high version, no prev) on the new epoch's plane.
8461        let mut refounded = updated.clone();
8462        refounded.root_epoch = crate::community::Epoch(1);
8463        crate::db::community::save_community_v2(&refounded).unwrap();
8464        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
8465
8466        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
8467        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
8468        // The persisted floor is stamped with the epoch the FOLD ran under.
8469        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8470        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
8471        assert!(
8472            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
8473            "the adopted head carries the fold's epoch + version"
8474        );
8475    }
8476
8477    #[tokio::test]
8478    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
8479        // Two owner-signed editions at the SAME version (publish retry / two owner
8480        // devices): every client must land on the lower-inner-id winner. A hash-strict
8481        // floor would wedge here forever while Armada converges — the floor must
8482        // CONVERGE instead (the v1 decide() rule).
8483        let (_tmp, _guard, owner) = init_test_db();
8484        let relay = MemoryRelay::new();
8485        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
8486        let session = SessionGuard::capture();
8487        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8488        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8489
8490        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
8491        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
8492        assert_eq!(ours.name, "Ours");
8493
8494        // Our committed v2 edition's tiebreak id.
8495        let our_inner = {
8496            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
8497            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
8498            wraps
8499                .iter()
8500                .find_map(|w| {
8501                    control::open_control_edition(w, &group)
8502                        .ok()
8503                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
8504                        .map(|(ed, _)| ed.inner_id)
8505                })
8506                .unwrap()
8507        };
8508
8509        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
8510        // authored timestamp until its inner id is lower).
8511        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
8512        let content = serde_json::to_string(&meta).unwrap();
8513        let mut ts = 2_000u64;
8514        let fork_wrap = loop {
8515            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
8516            let inner = rumor.id.unwrap().to_bytes();
8517            if inner < our_inner {
8518                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
8519            }
8520            ts += 1;
8521        };
8522        relay.publish(&fork_wrap, &community.relays).await.unwrap();
8523
8524        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
8525        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
8526        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8527        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
8528        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
8529    }
8530
8531    #[tokio::test]
8532    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
8533        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
8534        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
8535        // while the detached v4 waits. When v3 lands, the chain heals to v4.
8536        let (_tmp, _guard, owner) = init_test_db();
8537        let relay = MemoryRelay::new();
8538        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
8539        let session = SessionGuard::capture();
8540        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8541
8542        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
8543        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8544
8545        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
8546        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
8547        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
8548        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
8549        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
8550        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
8551        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
8552        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
8553        relay.publish(&w4, &community.relays).await.unwrap();
8554
8555        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
8556        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
8557
8558        relay.publish(&w3, &community.relays).await.unwrap();
8559        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
8560        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
8561    }
8562
8563    #[tokio::test]
8564    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
8565        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
8566        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
8567        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
8568        let (_tmp, _guard, owner) = init_test_db();
8569        let relay = MemoryRelay::new();
8570        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
8571        let session = SessionGuard::capture();
8572        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8573
8574        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
8575        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
8576        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
8577        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8578
8579        // Rogue flood occupying the newest window (sealed to the control plane, but
8580        // non-owner — the authority gate drops them; they only crowd the page).
8581        let rogue = Keys::generate();
8582        for i in 0..(FOLLOW_PAGE as u64 - 1) {
8583            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
8584            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
8585            relay.publish(&w, &community.relays).await.unwrap();
8586        }
8587        // v4 chained to the real v3 (crafted directly: the flood also blinds the
8588        // helper's own newest-window head lookup), timestamped newest of all.
8589        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
8590        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
8591        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
8592        relay.publish(&w4, &community.relays).await.unwrap();
8593
8594        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
8595        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
8596    }
8597
8598    #[tokio::test]
8599    async fn a_follow_after_delete_does_not_resurrect_the_community() {
8600        // A leave/delete racing an in-flight follow: the follow must not re-insert
8601        // the community row or floor rows past delete_community's wipe.
8602        let (_tmp, _guard, owner) = init_test_db();
8603        let relay = MemoryRelay::new();
8604        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
8605        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
8606        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8607        crate::db::community::delete_community(&cid_hex).unwrap();
8608
8609        let session = SessionGuard::capture();
8610        assert!(
8611            follow_control(&relay, &community, &session).await.unwrap().is_none(),
8612            "a follow racing a delete is a no-op"
8613        );
8614        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
8615        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
8616    }
8617
8618    #[tokio::test]
8619    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
8620        // The rekey sibling of the follow_control guard: an owner rotation adopted
8621        // mid-race must not upsert the community row back after a leave/delete.
8622        let (_tmp, _guard, owner) = init_test_db();
8623        let relay = MemoryRelay::new();
8624        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
8625        let new_root = [0xB2; 32];
8626        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
8627        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8628        crate::db::community::delete_community(&cid_hex).unwrap();
8629
8630        let session = SessionGuard::capture();
8631        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8632        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
8633        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
8634    }
8635
8636    #[tokio::test]
8637    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
8638        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
8639        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
8640        // would take v1 and SEED the joiner's floor there — pinning them below the
8641        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
8642        let (bed, owner, member) = TestBed::new();
8643        bed.swap_to(&owner);
8644        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
8645        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8646        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
8647
8648        // v2 is crafted but NEVER published; v3 chains to it and is published.
8649        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
8650        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
8651        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
8652        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
8653        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
8654        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);
8655        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
8656        bed.relay.publish(&w3, &community.relays).await.unwrap();
8657
8658        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
8659        let bundle_json = serde_json::to_string(&bundle).unwrap();
8660        bed.swap_to(&member);
8661        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8662        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
8663        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8664        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
8665        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
8666    }
8667
8668    #[tokio::test]
8669    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
8670        // The refusal half of fork convergence: a relay withholding OUR committed
8671        // floor edition while serving only a same-version fork with a HIGHER inner
8672        // id must be treated as withholding — held state and floor unchanged.
8673        let (_tmp, _guard, owner) = init_test_db();
8674        let relay = MemoryRelay::new();
8675        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
8676        let session = SessionGuard::capture();
8677        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8678        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
8679
8680        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
8681        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
8682        assert_eq!(ours.name, "Ours");
8683        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8684        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
8685        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
8686
8687        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
8688        // with the genesis but WITHOUT our v2 — from a withholding relay.
8689        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
8690        let content = serde_json::to_string(&meta).unwrap();
8691        let mut ts = 5_000u64;
8692        let fork_wrap = loop {
8693            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
8694            if rumor.id.unwrap().to_bytes() > our_inner {
8695                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
8696            }
8697            ts += 1;
8698        };
8699        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
8700        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
8701        let mut stale_view = ours.clone();
8702        stale_view.relays = vec!["wss://stale".into()];
8703
8704        assert!(
8705            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
8706            "a losing fork served without our floor edition changes nothing"
8707        );
8708        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
8709        assert_eq!(held_after, held_before, "the floor row is untouched");
8710        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8711        assert_eq!(held.name, "Ours", "the held state is untouched");
8712    }
8713
8714    #[tokio::test]
8715    async fn follow_control_ignores_a_non_owner_edition() {
8716        // A member holds the community_root, so they CAN seal a control edition —
8717        // but they aren't the owner, so the authority gate drops it (first cut:
8718        // owner-only). The rogue channel must never appear.
8719        let (_tmp, _guard, _owner) = init_test_db();
8720        let relay = MemoryRelay::new();
8721        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8722        let rogue = Keys::generate();
8723        let rogue_id = ChannelId([0x99; 32]);
8724        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
8725
8726        let session = SessionGuard::capture();
8727        assert!(
8728            follow_control(&relay, &community, &session).await.unwrap().is_none(),
8729            "a non-owner control edition is not folded"
8730        );
8731    }
8732
8733    #[tokio::test]
8734    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
8735        // A Private channel's key rides the rekey plane, not the control edition —
8736        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
8737        // every read/send path refuses it until the key lands (never the root plane).
8738        let (_tmp, _guard, owner) = init_test_db();
8739        let relay = MemoryRelay::new();
8740        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
8741        let priv_id = ChannelId([0x33; 32]);
8742        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
8743
8744        let session = SessionGuard::capture();
8745        let updated = follow_control(&relay, &community, &session)
8746            .await
8747            .unwrap()
8748            .expect("the keyless record is a change");
8749        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
8750        assert!(ch.private && ch.key.is_none(), "recorded keyless");
8751        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
8752        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
8753        assert!(
8754            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
8755            "a keyless fetch returns empty (and never queries the root plane)"
8756        );
8757        assert!(
8758            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
8759            "a keyless send refuses"
8760        );
8761        // The keyless record round-trips (the stored placeholder never surfaces
8762        // as a real key).
8763        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
8764        let rch = reloaded.channel(&priv_id).unwrap();
8765        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
8766        // And a bundle minted while keyless never carries the placeholder.
8767        let bundle = bundle_of(&reloaded, None, None, None);
8768        assert!(
8769            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
8770            "an ungrantable keyless channel stays out of invite bundles"
8771        );
8772    }
8773
8774    // ── Live rekey-follow ────────────────────────────────────────────────────
8775
8776    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
8777    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
8778    /// a stranger for the authority test); `prev_key` is the root it claims to
8779    /// extend (mismatch → a fork).
8780    async fn publish_base_rotation(
8781        relay: &MemoryRelay,
8782        community: &CommunityV2,
8783        rotator: &Keys,
8784        recipients: &[PublicKey],
8785        new_root: &[u8; 32],
8786        prev_key: &[u8; 32],
8787    ) {
8788        let new_epoch = Epoch(community.root_epoch.0 + 1);
8789        let prev_epoch = community.root_epoch;
8790        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
8791        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
8792        let blobs: Vec<_> = recipients
8793            .iter()
8794            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
8795            .collect();
8796        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()).unwrap();
8797        for e in &events {
8798            relay.publish(e, &community.relays).await.unwrap();
8799        }
8800    }
8801
8802    /// Attach a Private channel (key + epoch) to a held community and persist it.
8803    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
8804        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
8805        crate::db::community::save_community_v2(community).unwrap();
8806    }
8807
8808    #[tokio::test]
8809    async fn follow_rekeys_is_a_noop_without_rotations() {
8810        let (_tmp, _guard, _owner) = init_test_db();
8811        let relay = MemoryRelay::new();
8812        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
8813        let session = SessionGuard::capture();
8814        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8815        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
8816    }
8817
8818    #[tokio::test]
8819    async fn follow_rekeys_adopts_an_owner_base_rotation() {
8820        let (_tmp, _guard, owner) = init_test_db();
8821        let relay = MemoryRelay::new();
8822        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
8823        let new_root = [0xB1; 32];
8824        // Owner rotates the base to epoch 1, delivering the new root to me.
8825        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
8826
8827        let session = SessionGuard::capture();
8828        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
8829        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
8830        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
8831        // The public channel now reads under the NEW root/epoch (its address moved).
8832        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
8833        let general = updated.channels[0].id;
8834        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
8835        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
8836    }
8837
8838    #[tokio::test]
8839    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
8840        let (_tmp, _guard, owner) = init_test_db();
8841        let relay = MemoryRelay::new();
8842        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
8843        let priv_id = ChannelId([0x33; 32]);
8844        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
8845
8846        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
8847        let new_key = [0x55; 32];
8848        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
8849        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
8850        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();
8851        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
8852        for e in &events {
8853            relay.publish(e, &community.relays).await.unwrap();
8854        }
8855
8856        let session = SessionGuard::capture();
8857        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
8858        let ch = updated.channel(&priv_id).unwrap();
8859        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
8860        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
8861        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
8862    }
8863
8864    #[tokio::test]
8865    async fn follow_rekeys_ignores_a_non_owner_rotation() {
8866        // A member holds the community_root, so they can derive the rekey group key
8867        // and mint a rotation — but they aren't the owner, so it's not adopted.
8868        let (_tmp, _guard, _owner) = init_test_db();
8869        let relay = MemoryRelay::new();
8870        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8871        let rogue = Keys::generate();
8872        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
8873
8874        let session = SessionGuard::capture();
8875        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8876        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
8877    }
8878
8879    #[tokio::test]
8880    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
8881        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
8882        // extension — never adopted (would splice me onto an unrelated chain).
8883        let (_tmp, _guard, owner) = init_test_db();
8884        let relay = MemoryRelay::new();
8885        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8886        // prev_key ≠ the real community_root → the continuity check reads Fork.
8887        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
8888
8889        let session = SessionGuard::capture();
8890        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8891        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
8892    }
8893
8894    #[tokio::test]
8895    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
8896        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
8897        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
8898        let (_tmp, _guard, owner) = init_test_db();
8899        let relay = MemoryRelay::new();
8900        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
8901        let new_epoch = Epoch(1);
8902        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
8903        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
8904        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
8905        let other = Keys::generate();
8906        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
8907        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
8908        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
8909        relay.publish(&wrap, &community.relays).await.unwrap();
8910
8911        let session = SessionGuard::capture();
8912        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
8913        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
8914    }
8915
8916    #[tokio::test]
8917    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
8918        // Realistic two-actor removal: the owner Refounds the base and delivers the
8919        // new root to a THIRD party, not the member — a complete rotation with no
8920        // blob for the member is a removal.
8921        let (bed, owner, member) = TestBed::new();
8922        bed.swap_to(&owner);
8923        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
8924        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8925
8926        bed.swap_to(&member);
8927        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8928        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8929
8930        // Owner rotates, delivering only to a stranger (the member is dropped).
8931        bed.swap_to(&owner);
8932        let stranger = Keys::generate();
8933        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
8934
8935        // The member's follow concludes removal (a complete rotation without their blob).
8936        bed.swap_to(&member);
8937        let session = SessionGuard::capture();
8938        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8939        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
8940        assert!(follow.updated.is_none(), "a removed member adopts nothing");
8941    }
8942
8943    #[tokio::test]
8944    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
8945        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
8946        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
8947        // the base crate landed and was walked before the channel crates) must
8948        // still find them — the lookup fans across the archived roots, not just
8949        // the current one.
8950        let (_tmp, _guard, owner) = init_test_db();
8951        let relay = MemoryRelay::new();
8952        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
8953        let root0 = community.community_root;
8954        let priv_id = ChannelId([0x33; 32]);
8955        let key1 = [0x44; 32];
8956        add_private_channel(&mut community, priv_id, key1, Epoch(1));
8957
8958        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
8959        // root (root0), delivering the fresh key to me.
8960        let key2 = [0x55; 32];
8961        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
8962        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
8963        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();
8964        for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000, None).unwrap() {
8965            relay.publish(&e, &community.relays).await.unwrap();
8966        }
8967
8968        // Simulate the base having ALREADY advanced (the stranding order): the head
8969        // moved to a fresh root while root0 sits in the epoch-key archive (where
8970        // genesis put it).
8971        community.community_root = [0xB7; 32];
8972        community.root_epoch = Epoch(1);
8973        crate::db::community::save_community_v2(&community).unwrap();
8974
8975        let session = SessionGuard::capture();
8976        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
8977        let ch = updated.channel(&priv_id).unwrap();
8978        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
8979        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
8980    }
8981
8982    #[tokio::test]
8983    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
8984        // A keyless private channel (announced by vsk-2, key not yet held) has no
8985        // chain, so its epoch is a scan cursor: a complete rotation that excludes
8986        // us advances the cursor (never a removal — we were never in); a later
8987        // rotation that includes us is the entry point.
8988        let (_tmp, _guard, owner) = init_test_db();
8989        let relay = MemoryRelay::new();
8990        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
8991        let priv_id = ChannelId([0x66; 32]);
8992        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() });
8993        crate::db::community::save_community_v2(&community).unwrap();
8994        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8995        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
8996
8997        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
8998        let stranger = Keys::generate();
8999        let key1 = [0x71; 32];
9000        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9001        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
9002        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();
9003        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
9004            relay.publish(&e, &community.relays).await.unwrap();
9005        }
9006        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
9007        // recipient set is the CURRENT members).
9008        let key2 = [0x72; 32];
9009        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9010        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9011        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();
9012        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
9013            relay.publish(&e, &community.relays).await.unwrap();
9014        }
9015
9016        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
9017        // blob — adopt), because each real step re-loops.
9018        let session = SessionGuard::capture();
9019        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
9020        let ch = updated.channel(&priv_id).unwrap();
9021        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
9022        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
9023    }
9024
9025    #[tokio::test]
9026    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
9027        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
9028        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
9029        // rotation keys members up; a mere keyholder's forgery never does.
9030        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9031        let (_tmp, _guard, _owner) = init_test_db();
9032        let relay = MemoryRelay::new();
9033        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
9034        let priv_id = ChannelId([0x88; 32]);
9035        let key1 = [0x91; 32];
9036        add_private_channel(&mut community, priv_id, key1, Epoch(1));
9037
9038        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
9039        let admin = Keys::generate();
9040        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9041        let role = Role::admin("aa".repeat(32));
9042        let roster = CommunityRoles {
9043            roles: vec![role.clone()],
9044            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
9045        };
9046        seed_roster_with_heads(&community, &roster, 1_000);
9047
9048        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
9049        let key2 = [0x92; 32];
9050        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9051        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9052        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9053        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9054        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()).unwrap() {
9055            relay.publish(&e, &community.relays).await.unwrap();
9056        }
9057        let session = SessionGuard::capture();
9058        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
9059        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
9060
9061        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
9062        let rogue = Keys::generate();
9063        let key3 = [0x93; 32];
9064        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
9065        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
9066        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
9067        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
9068            relay.publish(&e, &updated.relays).await.unwrap();
9069        }
9070        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
9071        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
9072    }
9073
9074    #[tokio::test]
9075    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
9076        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
9077        // target. An equal-rank bit-holder's complete rotation that skips my blob
9078        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
9079        // two-account bed: the follower must be a NON-owner admin.
9080        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9081        let (bed, owner, member) = TestBed::new();
9082        bed.swap_to(&owner);
9083        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
9084
9085        // The MEMBER's device: holds the community + the private channel, with a
9086        // persisted roster granting the member AND a peer the same Admin role.
9087        bed.swap_to(&member);
9088        let mut held = community.clone();
9089        let priv_id = ChannelId([0xAB; 32]);
9090        let key1 = [0xA1; 32];
9091        add_private_channel(&mut held, priv_id, key1, Epoch(1));
9092        let peer = Keys::generate();
9093        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9094        let role = Role::admin("bb".repeat(32));
9095        let roster = CommunityRoles {
9096            roles: vec![role.clone()],
9097            grants: vec![
9098                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
9099                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
9100            ],
9101        };
9102        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
9103
9104        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
9105        let key2 = [0xA2; 32];
9106        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9107        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
9108        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();
9109        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()).unwrap() {
9110            bed.relay.publish(&e, &held.relays).await.unwrap();
9111        }
9112        let session = SessionGuard::capture();
9113        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
9114        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
9115        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
9116        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
9117
9118        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
9119        let key3 = [0xA3; 32];
9120        let stranger = Keys::generate();
9121        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();
9122        for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100, None).unwrap() {
9123            bed.relay.publish(&e, &held.relays).await.unwrap();
9124        }
9125        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
9126        let updated = follow.updated.expect("the owner's removal folds");
9127        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
9128    }
9129
9130    #[tokio::test]
9131    async fn converting_a_public_channel_to_private_is_refused() {
9132        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
9133        // the producer refuses the flag flip, so no reader is left unkeyable.
9134        let (_tmp, _guard, _owner) = init_test_db();
9135        let relay = MemoryRelay::new();
9136        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
9137        let general = community.channels[0].id;
9138        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
9139        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
9140        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
9141        // A rename of the same public channel still works.
9142        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
9143        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
9144    }
9145
9146    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
9147    /// `removed_at` — simulating a sibling device having left that community.
9148    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
9149        let doc = super::super::list::CommunityList {
9150            entries: vec![],
9151            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
9152            extra: Default::default(),
9153        };
9154        let event = super::super::list::build_list_event(me, &doc).unwrap();
9155        relay.publish(&event, relays).await.unwrap();
9156    }
9157
9158    #[tokio::test]
9159    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
9160        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
9161        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
9162        // which would silently undo the leave everywhere.
9163        let (_tmp, _guard, me) = init_test_db();
9164        let relay = MemoryRelay::new();
9165        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
9166        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9167
9168        // A sibling leaves X: a remote tombstone strictly newer than X's add.
9169        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
9170
9171        // Now join a different community Y → republish(just_joined = Y).
9172        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
9173        republish_community_list(&relay, Some(y.id())).await.unwrap();
9174
9175        // X must still read as LEFT in the published list; Y must be live.
9176        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
9177        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
9178        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
9179    }
9180
9181    #[tokio::test]
9182    async fn sync_tears_down_a_community_a_sibling_left() {
9183        // W1 (receive side): a community still held locally that the synced 13302
9184        // shows tombstoned-and-not-live is torn down, so a leave propagates.
9185        let (_tmp, _guard, me) = init_test_db();
9186        let relay = MemoryRelay::new();
9187        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
9188        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9189        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
9190
9191        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
9192        sync_community_list(&relay, &x.relays).await.unwrap();
9193        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
9194    }
9195
9196    #[tokio::test]
9197    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
9198        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
9199        // the tombstone), so a later sync keeps it.
9200        let (_tmp, _guard, me) = init_test_db();
9201        let relay = MemoryRelay::new();
9202        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
9203        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
9204        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
9205        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
9206        // Re-record the membership (a re-join) → live entry at now >> 1.
9207        republish_community_list(&relay, Some(x.id())).await.unwrap();
9208        sync_community_list(&relay, &x.relays).await.unwrap();
9209        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
9210    }
9211
9212    #[tokio::test]
9213    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
9214        // W2: a transient fetch failure during republish must not drive the
9215        // replaceable-event write (which would drop other entries / regress seeds).
9216        let (_tmp, _guard, _me) = init_test_db();
9217        let good = MemoryRelay::new();
9218        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
9219        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
9220
9221        // A transport whose fetch always errors: republish must bail, publishing nothing.
9222        struct FetchErrors;
9223        #[async_trait::async_trait]
9224        impl Transport for FetchErrors {
9225            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9226            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9227                panic!("republish must NOT publish when the remote fetch failed");
9228            }
9229            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9230                Ok(())
9231            }
9232            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9233                Err("relay unreachable".to_string())
9234            }
9235        }
9236        // Returns Ok (best-effort) but must not have published (the panic guards it).
9237        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
9238    }
9239
9240    #[tokio::test]
9241    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
9242        // B1 regression: refound_community's recipient set = memberlist. A member
9243        // the owner GRANTED a role to but who never left a (surviving) Guestbook
9244        // Join — a lurking admin, or one whose Join aged out of the window — must
9245        // still be a rekey recipient, or the Refounding SEVERS them. The folded
9246        // roster's granted members are the consensus-complete backstop.
9247        let (_tmp, _guard, owner) = init_test_db();
9248        let relay = MemoryRelay::new();
9249        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
9250
9251        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
9252        let lurker = Keys::generate();
9253        let rid = "b1".repeat(32);
9254        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9255        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
9256
9257        // memberlist includes the lurker purely via the roster backstop.
9258        let members = memberlist(&relay, &community).await.unwrap();
9259        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
9260
9261        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
9262        let banned_grantee = Keys::generate();
9263        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
9264        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
9265        let members = memberlist(&relay, &community).await.unwrap();
9266        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
9267        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
9268
9269        // And the Refounding actually delivers the new root to the lurker.
9270        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
9271        assert_eq!(refounded.root_epoch, Epoch(1));
9272        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
9273        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
9274        let rotations = rekey::collect_rotations(&chunks);
9275        let lurker_x = lurker.public_key().to_bytes();
9276        let delivered = rotations.iter().any(|r| {
9277            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
9278        });
9279        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
9280    }
9281
9282    #[tokio::test]
9283    async fn the_memberlist_pages_past_a_guestbook_flood() {
9284        // The roleless-member half of B1: >500 Guestbook events must not evict an
9285        // honest member's Join from the counted set (an insider can flood throwaway
9286        // Joins to force exactly this). The pager sees them all.
9287        let (_tmp, _guard, _owner) = init_test_db();
9288        let relay = MemoryRelay::new();
9289        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
9290        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
9291
9292        // An honest member's Join (oldest), then 600 throwaway Joins on top.
9293        let honest = Keys::generate();
9294        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
9295        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
9296        relay.publish(&w, &community.relays).await.unwrap();
9297        for i in 0..600u64 {
9298            let throwaway = Keys::generate();
9299            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
9300            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
9301            relay.publish(&w, &community.relays).await.unwrap();
9302        }
9303
9304        let members = memberlist(&relay, &community).await.unwrap();
9305        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
9306    }
9307
9308    #[tokio::test]
9309    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
9310        // An insider floods the next-epoch rekey address (community_root-derived,
9311        // so any member can seal there) with >200 junk 3303s to push the owner's
9312        // genuine rotation out of a single fetch window. The paginated fetch must
9313        // still recover it and adopt.
9314        let (_tmp, _guard, owner) = init_test_db();
9315        let relay = MemoryRelay::new();
9316        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
9317        let new_root = [0xD9; 32];
9318        let new_epoch = Epoch(1);
9319        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
9320
9321        // The GENUINE owner rotation lands first (oldest).
9322        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
9323
9324        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
9325        // (newer), burying the genuine one past the 200 newest.
9326        let rogue = Keys::generate();
9327        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
9328        for i in 0..260u64 {
9329            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
9330            let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i, None).unwrap();
9331            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
9332            relay.publish(&wrap, &community.relays).await.unwrap();
9333        }
9334
9335        let session = SessionGuard::capture();
9336        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
9337        assert_eq!(updated.root_epoch, Epoch(1));
9338        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
9339    }
9340
9341    #[tokio::test]
9342    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
9343        // create_private_channel straddles a memberlist fetch (seconds long) then
9344        // whole-row-saves. A swap in that window must abort — never mint a channel
9345        // into the swapped-in account, and never leave a half-published key crate
9346        // adopted locally.
9347        let (bed, owner, _member) = TestBed::new();
9348        bed.swap_to(&owner);
9349        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
9350        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
9351
9352        // The memberlist fetch inside create bumps the generation mid-flight.
9353        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
9354        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
9355        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
9356        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9357        assert_eq!(after.channels.len(), before, "no channel row was written");
9358        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
9359    }
9360
9361    #[tokio::test]
9362    async fn an_uncited_admin_rotation_is_not_adopted() {
9363        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
9364        // authority action, so a just-demoted admin's rotation is never honored by
9365        // a lagging client." An uncited rotation is skipped entirely — neither
9366        // adopted nor allowed to conclude a removal.
9367        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9368        let (_tmp, _guard, _owner) = init_test_db();
9369        let relay = MemoryRelay::new();
9370        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
9371        let priv_id = ChannelId([0x8A; 32]);
9372        let key1 = [0x93; 32];
9373        add_private_channel(&mut community, priv_id, key1, Epoch(1));
9374
9375        let admin = Keys::generate();
9376        let role = Role::admin("cf".repeat(32));
9377        let roster = CommunityRoles {
9378            roles: vec![role.clone()],
9379            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
9380        };
9381        seed_roster_with_heads(&community, &roster, 1_000);
9382
9383        let key2 = [0x94; 32];
9384        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9385        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9386        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9387        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9388        // Authorized admin, correct continuity, my blob present — but NO citation.
9389        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
9390            relay.publish(&e, &community.relays).await.unwrap();
9391        }
9392
9393        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
9394        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
9395
9396        // The SAME rotation, cited, is adopted — proving the refusal was the
9397        // citation and not the rank or the continuity.
9398        let cited = my_authority_citation(&community, &admin.public_key());
9399        assert!(cited.is_some(), "the seeded head yields a citation");
9400        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
9401        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()).unwrap() {
9402            relay.publish(&e, &community.relays).await.unwrap();
9403        }
9404        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
9405        assert!(out.updated.is_some(), "the cited rotation IS adopted");
9406    }
9407
9408    #[tokio::test]
9409    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
9410        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
9411        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
9412        // Every follower must converge on the SAME key (the lexicographically
9413        // lowest), so the community never permanently forks. (Retaining the losing
9414        // fork's key for its race-window messages needs a multi-key-per-epoch
9415        // archive — a deferred refinement shared with v1; convergence, the
9416        // security-critical property, is what this pins.)
9417        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
9418        let (_tmp, _guard, _owner) = init_test_db();
9419        let relay = MemoryRelay::new();
9420        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
9421        let priv_id = ChannelId([0xC0; 32]);
9422        let key1 = [0xC1; 32];
9423        add_private_channel(&mut community, priv_id, key1, Epoch(1));
9424
9425        // Two admins (a, b) both hold the Admin role; I hold the channel key.
9426        let (a, b) = (Keys::generate(), Keys::generate());
9427        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9428        let role = Role::admin("ce".repeat(32));
9429        let roster = CommunityRoles {
9430            roles: vec![role.clone()],
9431            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
9432        };
9433        seed_roster_with_heads(&community, &roster, 1_000);
9434
9435        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
9436        // same prevcommit — a genuine same-epoch fork.
9437        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
9438        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
9439        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
9440        let key_a = [0x0A; 32];
9441        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
9442        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
9443            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
9444            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()).unwrap() {
9445                relay.publish(&e, &community.relays).await.unwrap();
9446            }
9447        }
9448
9449        let session = SessionGuard::capture();
9450        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
9451        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
9452        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
9453
9454        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
9455        let mut peer = community.clone();
9456        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9457            c.key = Some(key1);
9458            c.epoch = Epoch(1);
9459        }
9460        // Re-run the same fold from the peer's identical starting point → same winner.
9461        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
9462        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
9463    }
9464
9465    #[tokio::test]
9466    async fn create_private_channel_refuses_a_member_without_manage_channels() {
9467        // The local mirror of the reader's gate: an unauthorized member is refused
9468        // BEFORE any publish (no floor pollution, no orphan key crate).
9469        let (bed, owner, member) = TestBed::new();
9470        bed.swap_to(&owner);
9471        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
9472        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9473
9474        bed.swap_to(&member);
9475        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9476        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9477        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
9478        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
9479        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
9480        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
9481    }
9482
9483    // ── Audit regressions ────────────────────────────────────────────────────
9484
9485    #[tokio::test]
9486    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
9487        // The eclipse: community_id commits only to (owner, salt) — both semi-public
9488        // — so a forged invite pairs the REAL triple with an attacker root, and every
9489        // plane derives from it. The join-time owner-genesis check must refuse.
9490        let (bed, owner, member) = TestBed::new();
9491        bed.swap_to(&owner);
9492        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
9493
9494        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
9495        let mut forged = bundle_of(&community, None, None, None);
9496        forged.community_root = fake.clone();
9497        for ch in &mut forged.channels {
9498            ch.key = fake.clone();
9499        }
9500        let attacker = Keys::generate();
9501        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
9502        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
9503
9504        bed.swap_to(&member);
9505        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9506        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
9507        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
9508        assert!(
9509            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
9510            "a rejected join persists nothing"
9511        );
9512    }
9513
9514    #[tokio::test]
9515    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
9516        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
9517        // so a rotated plane whose metadata an admin last edited carries no
9518        // owner-signed vsk-0. The join anchor there is the community-bound metadata
9519        // head plus any owner-signed edition under the same root.
9520        let (bed, owner, member) = TestBed::new();
9521        bed.swap_to(&owner);
9522        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
9523        let general = community.channels[0].id;
9524
9525        let mut rotated = community.clone();
9526        rotated.community_root = [0x5A; 32];
9527        rotated.root_epoch = Epoch(1);
9528        let admin = Keys::generate();
9529        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
9530        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
9531
9532        bed.swap_to(&member);
9533        let bundle = bundle_of(&rotated, None, None, None);
9534        let session = SessionGuard::capture();
9535        let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9536        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
9537    }
9538
9539    #[tokio::test]
9540    async fn only_an_actual_join_publishes_a_guestbook_join() {
9541        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
9542        // a held community and a cross-device key sync (announce_join=false) must
9543        // both stay silent — each re-publish renders as "<user> has joined" spam.
9544        let (bed, owner, member) = TestBed::new();
9545        bed.swap_to(&owner);
9546        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
9547
9548        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
9549        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
9550            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
9551            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
9552        }
9553        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
9554
9555        bed.swap_to(&member);
9556        let bundle = bundle_of(&community, None, None, None);
9557        let session = SessionGuard::capture();
9558        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9559        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
9560
9561        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
9562        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
9563
9564        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9565        crate::db::community::delete_community(&cid_hex).unwrap();
9566        accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
9567        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
9568    }
9569
9570    #[tokio::test]
9571    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
9572        // The fallback's second half is load-bearing: a community-bound metadata
9573        // head alone is self-signable by anyone who knows the (public) community_id.
9574        let (bed, owner, member) = TestBed::new();
9575        bed.swap_to(&owner);
9576        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
9577
9578        let mut rotated = community.clone();
9579        rotated.community_root = [0x5B; 32];
9580        rotated.root_epoch = Epoch(1);
9581        let attacker = Keys::generate();
9582        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
9583
9584        bed.swap_to(&member);
9585        let bundle = bundle_of(&rotated, None, None, None);
9586        let session = SessionGuard::capture();
9587        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
9588        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
9589    }
9590
9591    #[tokio::test]
9592    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
9593        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
9594        // an owner-signed genesis, so owner material without it stays insufficient.
9595        let (bed, owner, member) = TestBed::new();
9596        bed.swap_to(&owner);
9597        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
9598        let general = community.channels[0].id;
9599
9600        let mut fake = community.clone();
9601        fake.community_root = [0x5C; 32]; // epoch stays 0
9602        let admin = Keys::generate();
9603        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
9604        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
9605
9606        bed.swap_to(&member);
9607        let bundle = bundle_of(&fake, None, None, None);
9608        let session = SessionGuard::capture();
9609        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
9610        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
9611    }
9612
9613    #[tokio::test]
9614    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
9615        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
9616        // joiner addresses it at a plane only the attacker reads. The owner's genuine
9617        // public:false edition must override it on follow.
9618        let (_tmp, _guard, _owner) = init_test_db();
9619        let relay = MemoryRelay::new();
9620        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
9621        let general = community.channels[0].id;
9622        let mut poisoned = community.clone();
9623        poisoned.channels[0].private = true;
9624        poisoned.channels[0].key = Some([0x66; 32]);
9625        crate::db::community::save_community_v2(&poisoned).unwrap();
9626
9627        let session = SessionGuard::capture();
9628        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
9629        let ch = healed.channel(&general).unwrap();
9630        assert!(!ch.private, "the owner's public declaration overrides the bundle");
9631        assert_eq!(ch.key, None, "a healed public channel derives from the root");
9632    }
9633
9634    #[tokio::test]
9635    async fn a_deleted_channel_does_not_resurrect_on_reload() {
9636        // save_community_v2 must prune orphan channel rows, or a control-follow delete
9637        // reappears (with a stale key) on the next reload.
9638        let (_tmp, _guard, owner) = init_test_db();
9639        let relay = MemoryRelay::new();
9640        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9641        let extra = ChannelId([0x77; 32]);
9642        let session = SessionGuard::capture();
9643        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9644        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9645        assert!(with_extra.channel(&extra).is_some());
9646        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9647        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
9648        assert!(after.channel(&extra).is_none());
9649
9650        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9651        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
9652        assert_eq!(reloaded.channels.len(), 1);
9653    }
9654
9655    #[tokio::test]
9656    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
9657        // channel_id is the sole DB primary key, so a bundle/replay reusing another
9658        // community's channel_id must NOT overwrite that row. It's skipped (not an
9659        // error — erroring would wedge all of this community's control persistence).
9660        let (_tmp, _guard, _owner) = init_test_db();
9661        let relay = MemoryRelay::new();
9662        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
9663        let a_channel = a.channels[0].id;
9664        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
9665        let b_channel = b.channels[0].id;
9666        // B's set includes a phantom whose id collides with A's channel.
9667        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() });
9668
9669        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
9670        // A's channel row is untouched.
9671        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
9672        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
9673        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
9674        // B keeps its own channel but never acquired a row for the foreign id.
9675        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
9676        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
9677        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
9678    }
9679
9680    /// A single relay that CAPS every query below the page size (modelling a real
9681    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
9682    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
9683    /// truncates the whole set), which is why a MemoryRelay flood test gives false
9684    /// confidence about the production `LiveTransport` behaviour.
9685    struct CappedRelay {
9686        events: Vec<Event>,
9687        cap: usize,
9688    }
9689    #[async_trait::async_trait]
9690    impl Transport for CappedRelay {
9691        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
9692        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9693            Ok(())
9694        }
9695        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
9696            Ok(())
9697        }
9698        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
9699            let mut m: Vec<Event> = self
9700                .events
9701                .iter()
9702                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
9703                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
9704                .cloned()
9705                .collect();
9706            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
9707            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
9708            Ok(m)
9709        }
9710    }
9711
9712    #[tokio::test]
9713    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
9714        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
9715        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
9716        // no cursor steps past — everything older (the genesis editions, a Banlist)
9717        // is unreachable. Compacting THAT view carries only what was read into the
9718        // new epoch, dropping the rest for every member, permanently. Any member can
9719        // build the wall: the plane key comes from the community root they hold.
9720        let (_tmp, _guard, _owner) = init_test_db();
9721        let memory = MemoryRelay::new();
9722        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
9723        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
9724
9725        let rogue = Keys::generate();
9726        let mut events: Vec<Event> = Vec::new();
9727        for i in 0..FOLLOW_PAGE {
9728            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
9729            let rumor = control::build_edition_rumor(
9730                rogue.public_key(),
9731                vsk::CHANNEL_METADATA,
9732                &[0xAB; 32],
9733                1,
9734                None,
9735                &content,
9736                9_000,
9737                None,
9738            );
9739            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
9740            events.push(w);
9741        }
9742        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
9743
9744        let err = refound_community(&relay, &community, &[])
9745            .await
9746            .expect_err("a plane that can't be read whole must never be compacted");
9747        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
9748    }
9749
9750    #[tokio::test]
9751    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
9752        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
9753        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
9754        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
9755        // timestamps so `until = now` includes everything and the walk is deterministic.
9756        let (_tmp, _guard, owner) = init_test_db();
9757        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
9758        let g = control::genesis(&owner, meta, 1_000).unwrap();
9759        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
9760
9761        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
9762        let rogue = Keys::generate();
9763        let mut events: Vec<Event> = g.wraps.to_vec();
9764        for i in 0..250u64 {
9765            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
9766            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
9767            events.push(wrap);
9768        }
9769        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
9770        let relay = CappedRelay { events, cap: 100 };
9771        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
9772        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
9773    }
9774
9775    #[tokio::test]
9776    async fn accept_parked_invite_joins_from_the_stored_bundle() {
9777        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
9778        // from the stored bundle (re-verifying the owner root over the network).
9779        let (bed, owner, member) = TestBed::new();
9780        bed.swap_to(&owner);
9781        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
9782        let general = community.channels[0].id;
9783        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
9784        let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
9785        let bundle_json = serde_json::to_string(&bundle).unwrap();
9786        let inviter_hex = owner.keys.public_key().to_hex();
9787
9788        bed.swap_to(&member);
9789        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
9790        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
9791        assert!(joined.identity.verify());
9792        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
9793        // The join seeded the verified fold as the member's initial floor, so their
9794        // first follow can't roll below the state the join just showed.
9795        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9796        assert!(
9797            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
9798            "the joiner's control floor is seeded from the join-time fold"
9799        );
9800
9801        // The Guestbook memberlist now folds both participants.
9802        bed.swap_to(&owner);
9803        let members = memberlist(&bed.relay, &community).await.unwrap();
9804        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
9805    }
9806
9807    #[tokio::test]
9808    async fn accept_parked_invite_rejects_a_forged_root() {
9809        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
9810        // accept — the shared accept path re-verifies the owner root, so a parked invite
9811        // gets the same eclipse protection as a live one.
9812        let (_tmp, _guard, _owner) = init_test_db();
9813        let relay = MemoryRelay::new();
9814        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
9815        let mut forged = bundle_of(&community, None, None, None);
9816        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
9817        forged.community_root = fake.clone();
9818        for ch in &mut forged.channels {
9819            ch.key = fake.clone();
9820        }
9821        let bundle_json = serde_json::to_string(&forged).unwrap();
9822
9823        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
9824        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
9825    }
9826
9827    #[test]
9828    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
9829        // The protocol discriminator the facade list/accept relies on: a v2 bundle
9830        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
9831        // one does not, so a parked invite routes to the right accept path.
9832        let owner = Keys::generate();
9833        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
9834        let hex = crate::simd::hex::bytes_to_hex_32;
9835        let v2 = invite::CommunityInvite {
9836            community_id: hex(&identity.community_id.0),
9837            owner: hex(&identity.owner_xonly),
9838            owner_salt: hex(&identity.owner_salt),
9839            community_root: hex(&[0x11; 32]),
9840            root_epoch: 0,
9841            channels: vec![],
9842            relays: vec!["wss://r".into()],
9843            name: "V2".into(),
9844            icon: None,
9845            expires_at: None,
9846            creator_npub: None,
9847            label: None,
9848            extra: Default::default(),
9849        };
9850        let v2_json = serde_json::to_string(&v2).unwrap();
9851        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
9852        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
9853        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
9854    }
9855
9856    #[tokio::test]
9857    async fn verify_rejects_a_cross_community_owner_edition_replay() {
9858        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
9859        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
9860        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
9861        // a different eid — is not a valid proof of T's root. This is why "any owner
9862        // edition" is unsound and the eid==community_id genesis pin is required.
9863        let (_tmp, _guard, owner) = init_test_db();
9864
9865        // Community X (real), owned by `owner`.
9866        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
9867        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
9868        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
9869
9870        // Forged community T: the real owner triple but an ATTACKER-chosen root.
9871        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
9872        let fake_root = [0xEE; 32];
9873        let t = CommunityV2 {
9874            identity: t_identity,
9875            community_root: fake_root,
9876            root_epoch: Epoch(0),
9877            name: "T".into(),
9878            description: None,
9879            icon: None,
9880            banner: None,
9881            meta_custom: None,
9882            meta_extra: Default::default(),
9883            relays: vec!["wss://r".into()],
9884            channels: vec![],
9885            dissolved: false,
9886            created_at_ms: 0,
9887        };
9888        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
9889        // controls the fake root, so they can derive its control group key).
9890        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
9891        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
9892        let relay = MemoryRelay::new();
9893        relay.publish(&replayed, &t.relays).await.unwrap();
9894
9895        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
9896        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
9897    }
9898
9899    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
9900    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
9901    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
9902    /// it never touches real accounts. Run explicitly:
9903    /// ```sh
9904    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
9905    /// ```
9906    #[tokio::test]
9907    #[ignore = "hits a real relay over the network"]
9908    async fn live_smoke_create_send_fetch_on_a_real_relay() {
9909        use crate::community::transport::LiveTransport;
9910        use nostr_sdk::prelude::ToBech32;
9911
9912        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
9913        let relays = vec![relay.clone()];
9914
9915        // Isolated account + data dir (a fresh throwaway key — never a real account).
9916        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9917        crate::db::close_database();
9918        crate::db::clear_id_caches();
9919        let tmp = tempfile::tempdir().unwrap();
9920        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
9921        // into elsewhere; otherwise a fresh throwaway.
9922        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
9923            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
9924            Err(_) => Keys::generate(),
9925        };
9926        let npub = keys.public_key().to_bech32().unwrap();
9927        // Off by default (never leak secrets from a committed test); set
9928        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
9929        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
9930            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
9931        }
9932        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
9933        crate::db::set_app_data_dir(tmp.path().to_path_buf());
9934        crate::db::set_current_account(npub.clone()).unwrap();
9935        crate::db::init_database(&npub).unwrap();
9936        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
9937        crate::state::set_my_public_key(keys.public_key());
9938        println!("[smoke] throwaway identity {npub}");
9939
9940        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
9941        let client = crate::nostr_client_builder().build();
9942        client.add_managed_relay(relay.as_str()).await.ok();
9943        client.connect().await;
9944        crate::state::set_nostr_client(client);
9945        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
9946
9947        // Create → send → fetch-back → verify.
9948        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
9949        let general = community.channels[0].id;
9950        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
9951
9952        let text = "hello from a Vector Concord v2 live smoke test";
9953        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
9954        println!("[smoke] sent message {sent_id}");
9955
9956        // Give the relay a moment to store + be ready to serve it.
9957        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
9958
9959        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
9960        let texts: Vec<String> = page
9961            .iter()
9962            .filter_map(|f| match &f.event {
9963                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
9964                _ => None,
9965            })
9966            .collect();
9967        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
9968        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
9969
9970        // Mint a shareable v2 link (the thing a bot hands out).
9971        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
9972        println!("[smoke] invite link: {}", link.url);
9973        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
9974    }
9975
9976    #[tokio::test]
9977    async fn chat_ops_react_edit_delete_round_trip() {
9978        let (bed, owner, _member) = TestBed::new();
9979        bed.swap_to(&owner);
9980        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
9981        let general = community.channels[0].id;
9982        let me_hex = owner.keys.public_key().to_hex();
9983
9984        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
9985        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
9986            .await
9987            .unwrap();
9988        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
9989        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
9990
9991        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
9992        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
9993        let mut saw = (false, false, false);
9994        for f in &page {
9995            match &f.event {
9996                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
9997                    assert_eq!(emoji, ":fire:");
9998                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
9999                    saw.0 = true;
10000                }
10001                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
10002                    assert_eq!(new_content, "edited");
10003                    saw.1 = true;
10004                }
10005                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
10006                _ => {}
10007            }
10008        }
10009        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
10010    }
10011
10012    #[tokio::test]
10013    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
10014        let (bed, owner, _member) = TestBed::new();
10015        bed.swap_to(&owner);
10016        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
10017        let general = community.channels[0].id;
10018        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10019
10020        // A live subscriber sees the 21059 wrap and it opens as Typing…
10021        let mut sub = bed.relay.subscribe(Query {
10022            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
10023            authors: vec![group.pk_hex()],
10024            ..Default::default()
10025        });
10026        send_typing(&bed.relay, &community, &general).await.unwrap();
10027        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
10028        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
10029            Ok(ChatEvent::Typing { opened }) => opened,
10030            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
10031        };
10032
10033        // …while nothing durable is stored (relays never keep the ephemeral tier),
10034        // so channel history stays free of typing noise…
10035        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10036        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
10037
10038        // …and no scrub key is retained (there is no durable wrap to ever delete).
10039        assert!(
10040            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
10041            "ephemeral sends must not retain scrub keys"
10042        );
10043    }
10044
10045    #[tokio::test]
10046    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
10047        let (bed, owner, _member) = TestBed::new();
10048        bed.swap_to(&owner);
10049        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
10050        let general = community.channels[0].id;
10051        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10052
10053        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
10054
10055        // Retained: the row maps the rumor id to the exact published wrap, holds the
10056        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
10057        let (keys, outer_hex, relays) =
10058            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
10059        assert_eq!(relays, community.relays);
10060        let wrap_query = Query {
10061            kinds: vec![stream::KIND_WRAP],
10062            authors: vec![group.pk_hex()],
10063            ..Default::default()
10064        };
10065        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
10066        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
10067        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
10068
10069        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
10070        let me_hex = owner.keys.public_key().to_hex();
10071        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
10072            .await
10073            .unwrap();
10074        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
10075
10076        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
10077        // scrubs the wrap off the relay via the retained key, then consumes the row.
10078        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
10079        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
10080        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
10081        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
10082    }
10083
10084    #[tokio::test]
10085    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
10086        let (bed, owner, _member) = TestBed::new();
10087        bed.swap_to(&owner);
10088        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
10089        let general = community.channels[0].id;
10090        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
10091
10092        // Simulate a pre-retention / other-device send: our message on the relay,
10093        // but no local mapping row.
10094        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
10095        crate::db::community::delete_message_key(&id).unwrap();
10096        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
10097
10098        // A stranger member's message rides the same channel.
10099        let mkeys = Keys::generate();
10100        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
10101        let foreign_id = rumor.id.unwrap().to_hex();
10102        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
10103        bed.relay.publish(&fw, &community.relays).await.unwrap();
10104
10105        // One history open re-derives the mapping for the OWN message…
10106        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10107        let (keys, _outer, relays) =
10108            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
10109        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
10110        assert_eq!(relays, community.relays);
10111
10112        // …and never manufactures one for a foreign author.
10113        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
10114
10115        // The healed row is a working full delete: the shared path scrubs the wrap.
10116        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
10117        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10118        assert!(
10119            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
10120            "healed message scrubbed from the relay"
10121        );
10122    }
10123
10124    #[tokio::test]
10125    async fn send_chat_message_threads_the_reply_and_extra_tags() {
10126        let (bed, owner, _member) = TestBed::new();
10127        bed.swap_to(&owner);
10128        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
10129        let general = community.channels[0].id;
10130        let me_hex = owner.keys.public_key().to_hex();
10131
10132        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
10133        let imeta = nostr_sdk::prelude::Tag::custom(
10134            "imeta",
10135            ["url https://e/blob".to_string(), "m image/png".to_string()],
10136        );
10137        let child_id = send_chat_message(
10138            &bed.relay, &community, &general, "child",
10139            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
10140        )
10141        .await
10142        .unwrap();
10143
10144        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
10145        let child = page
10146            .iter()
10147            .find_map(|f| match &f.event {
10148                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
10149                _ => None,
10150            })
10151            .expect("the reply message round-trips");
10152        let reply = child.1.as_ref().expect("the reply reference is carried");
10153        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
10154        assert_eq!(reply.author, Some(owner.keys.public_key()));
10155        assert!(
10156            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
10157            "the imeta attachment tag rides the rumor verbatim"
10158        );
10159    }
10160
10161    #[tokio::test]
10162    async fn a_kick_needs_kick_authority_and_removes_the_target() {
10163        let (bed, owner, member) = TestBed::new();
10164        bed.swap_to(&owner);
10165        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
10166
10167        // The target announces a Join (as an accepted invite would).
10168        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10169        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
10170        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
10171        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10172        let before = memberlist(&bed.relay, &community).await.unwrap();
10173        assert!(before.contains(&member.keys.public_key()), "the join lands first");
10174
10175        // An unprivileged member's kick of the owner is refused locally…
10176        bed.swap_to(&member);
10177        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
10178        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
10179
10180        // …and the owner (supreme, no grant needed) kicks the member out.
10181        bed.swap_to(&owner);
10182        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10183        let after = memberlist(&bed.relay, &community).await.unwrap();
10184        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
10185        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
10186    }
10187
10188    #[tokio::test]
10189    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
10190        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
10191        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
10192        // legitimately excludes them for that window. A stale Kick landing there used to
10193        // read as an authorized eviction and the client nuked its own community.
10194        let (bed, owner, member) = TestBed::new();
10195        bed.swap_to(&owner);
10196        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
10197        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10198        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
10199        let join = |at: u64, id: u8| guestbook::GuestbookEvent {
10200            rumor_id: [id; 32],
10201            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
10202        };
10203        let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
10204            rumor_id: [id; 32],
10205            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
10206        };
10207
10208        // An authorized kick after their join stands.
10209        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
10210        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
10211
10212        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
10213        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
10214        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
10215
10216        // The catch-up window itself: nothing folded yet decides nothing.
10217        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
10218        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
10219
10220        // And the memberlist is NOT a substitute: with the store empty it excludes them,
10221        // which is exactly the false positive this verdict replaced.
10222        assert!(
10223            !stored_memberlist(&community).unwrap().contains(&m),
10224            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
10225        );
10226    }
10227
10228    /// Seed a roster the way production does: `follow_control` writes the roster
10229    /// AND the folded edition heads in one pass, so a citation against a grant is
10230    /// resolvable. Seeding the roster alone yields a client that can never satisfy
10231    /// any `vac` — a shape no v2 production path produces.
10232    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
10233        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10234        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
10235        for g in &roster.grants {
10236            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
10237            let eid = super::super::derive::grant_locator(community.id(), &m);
10238            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
10239            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
10240        }
10241    }
10242
10243    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
10244    async fn publish_grant_citing(
10245        relay: &MemoryRelay,
10246        community: &CommunityV2,
10247        signer: &Keys,
10248        member: &PublicKey,
10249        role_ids: Vec<String>,
10250        version: u64,
10251        citation: Option<&crate::community::edition::AuthorityCitation>,
10252    ) {
10253        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10254        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
10255        let prev = head_hash_on_relay(relay, community, &eid).await;
10256        let grant = MemberGrant { member: member.to_hex(), role_ids };
10257        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
10258        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
10259        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
10260        relay.publish(&wrap, &community.relays).await.unwrap();
10261    }
10262
10263    #[tokio::test]
10264    async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
10265        // CORD-04 §5 on the CONTROL PLANE: "a verifier won't act on the edition
10266        // until it has synced at least that Grant". The citation resolves against
10267        // the heads THIS fold accepted — an external floor would refuse every
10268        // non-owner edition on a bootstrap and the roster could never fold.
10269        let (bed, owner, admin) = TestBed::new();
10270        bed.swap_to(&owner);
10271        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
10272        let admin_pk = admin.keys.public_key();
10273        let rid = "c3".repeat(32);
10274        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
10275        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
10276
10277        // The admin grants a bystander, citing NOTHING.
10278        // A LOWER role (position 5) — an admin at position 1 may grant beneath
10279        // themselves but never at their own rank (equal cannot act on equal).
10280        let low_rid = "c4".repeat(32);
10281        let mut low = admin_role(&low_rid, Permissions::admin().0);
10282        low.position = 5;
10283        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
10284
10285        let bystander = Keys::generate().public_key();
10286        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
10287        let view = fetch_authority(&bed.relay, &community).await;
10288        assert!(
10289            !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
10290            "an uncited non-owner edition is not folded"
10291        );
10292        // The owner's own editions still fold — supreme cites nothing.
10293        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
10294
10295        // Same edition, now citing the admin's real grant: honored. (follow_control
10296        // is what PERSISTS the folded heads a citation is built from.)
10297        let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
10298        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
10299        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10300        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
10301        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
10302        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
10303        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
10304
10305        let view = fetch_authority(&bed.relay, &community).await;
10306        assert!(
10307            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
10308            "the same edition WITH its synced citation folds"
10309        );
10310    }
10311
10312    #[tokio::test]
10313    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
10314        // The invite is deliberately ungated, so a fresh Join can arrive seconds
10315        // BEFORE the unban edition. It must reach the store (banned = a fold
10316        // verdict, not a storage verdict) so the unban resurrects the member —
10317        // dropped at ingest, they stayed invisible forever.
10318        let (bed, owner, member) = TestBed::new();
10319        bed.swap_to(&owner);
10320        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
10321        let member_pk = member.keys.public_key();
10322        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10323
10324        // Locally banned (edition folded at t=1000s), with the outliving mark.
10325        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
10326        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
10327
10328        // Their Join lands 60s after the ban mark, while the banlist still says banned.
10329        let join = guestbook::GuestbookEvent {
10330            rumor_id: [9u8; 32],
10331            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
10332        };
10333        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
10334        assert!(
10335            !stored_memberlist(&community).unwrap().contains(&member_pk),
10336            "while banned, the fold keeps them out"
10337        );
10338
10339        // The unban folds: same store, no refetch needed — the Join resurrects them.
10340        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
10341        assert!(
10342            stored_memberlist(&community).unwrap().contains(&member_pk),
10343            "after the unban the raced Join makes them a member again"
10344        );
10345    }
10346
10347    #[tokio::test]
10348    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
10349        // The ban→unban race: a Ban's refound buries the old root over several
10350        // publishes while a concurrently-issued command still holds the
10351        // pre-commit struct. That unban used to land on the buried control
10352        // plane — "succeeding" while no reader would ever fold it — and a
10353        // concurrently-minted invite stranded its joiner on the dead epoch.
10354        let (bed, owner, member) = TestBed::new();
10355        bed.swap_to(&owner);
10356        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
10357        let member_pk = member.keys.public_key();
10358
10359        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
10360        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
10361
10362        // The stale-struct unban is REFUSED (retryable), never misdirected.
10363        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
10364        assert!(err.contains("re-founded"), "unban: {err}");
10365        // A stale invite must not mint dead-epoch key material.
10366        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
10367        assert!(err.contains("re-founded"), "invite: {err}");
10368        // Neither is a kick allowed to ride the buried guestbook.
10369        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
10370        assert!(err.contains("re-founded"), "kick: {err}");
10371
10372        // The retry path: a fresh load lands the unban on the LIVING plane.
10373        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10374        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
10375        let view = fetch_authority(&bed.relay, &fresh).await;
10376        assert!(view.banned.is_empty(), "the retried unban actually unbans");
10377    }
10378
10379    #[tokio::test]
10380    async fn an_uncited_kick_from_an_admin_is_not_honored() {
10381        // CORD-04 §5: a non-owner authority action must name the Grant it acts
10382        // under, and the reader refuses until it holds that Grant. Emitting the
10383        // `vac` without checking it buys nothing — a demoted admin's kick would
10384        // still land on any client that hadn't synced the demotion.
10385        let (bed, owner, member) = TestBed::new();
10386        bed.swap_to(&owner);
10387        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
10388        let admin = Keys::generate();
10389        let member_pk = member.keys.public_key();
10390        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
10391
10392        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10393        let view = fetch_authority(&bed.relay, &community).await;
10394        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
10395
10396        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
10397        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
10398        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
10399        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
10400
10401        let joined = guestbook::GuestbookEvent {
10402            rumor_id: [1u8; 32],
10403            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
10404        };
10405        let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
10406            rumor_id: [id; 32],
10407            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
10408        };
10409        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
10410        let empty_bans = std::collections::BTreeSet::new();
10411        let empty_marks = std::collections::BTreeMap::new();
10412        let fold = |evs: &[guestbook::GuestbookEvent]| {
10413            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
10414        };
10415
10416        assert!(
10417            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
10418            "an uncited kick from an admin is not honored"
10419        );
10420        assert!(
10421            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
10422            "the same kick WITH its synced citation removes them"
10423        );
10424    }
10425
10426    #[tokio::test]
10427    async fn kicking_an_admin_strips_their_roles_first() {
10428        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
10429        // strip leaves the target out of the memberlist but still holding every
10430        // management bit, so every client keeps honoring their control editions.
10431        let (bed, owner, member) = TestBed::new();
10432        bed.swap_to(&owner);
10433        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
10434        let member_pk = member.keys.public_key();
10435        let member_hex = member_pk.to_hex();
10436        let owner_hex = owner.keys.public_key().to_hex();
10437
10438        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10439        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
10440
10441        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
10442
10443        let view = fetch_authority(&bed.relay, &community).await;
10444        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
10445        assert!(
10446            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
10447            "a kicked admin holds no bit"
10448        );
10449        assert!(
10450            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
10451            "and the directive still removed them"
10452        );
10453    }
10454
10455    #[tokio::test]
10456    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
10457        let (bed, owner, member) = TestBed::new();
10458        bed.swap_to(&owner);
10459        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
10460        let member_pk = member.keys.public_key();
10461        let member_hex = member_pk.to_hex();
10462        let owner_hex = owner.keys.public_key().to_hex();
10463
10464        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10465        let view = fetch_authority(&bed.relay, &community).await;
10466        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
10467        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
10468
10469        // A second grant (any device) converges on the SAME role entity — and a
10470        // repeat is a no-op, not a version bump.
10471        let second = Keys::generate().public_key();
10472        grant_admin(&bed.relay, &community, &second).await.unwrap();
10473        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10474        let view = fetch_authority(&bed.relay, &community).await;
10475        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
10476        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
10477        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
10478        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
10479
10480        // Revoke strips ONLY the admin role and de-authorizes.
10481        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
10482        let view = fetch_authority(&bed.relay, &community).await;
10483        assert!(!view.roles.is_admin(&member_hex), "revoked");
10484        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
10485        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
10486    }
10487
10488    #[tokio::test]
10489    async fn follow_control_persists_the_roster_for_sync_local_reads() {
10490        let (bed, owner, member) = TestBed::new();
10491        bed.swap_to(&owner);
10492        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
10493        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10494        let member_hex = member.keys.public_key().to_hex();
10495        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10496
10497        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
10498        let session = crate::state::SessionGuard::capture();
10499        follow_control(&bed.relay, &community, &session).await.unwrap();
10500        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10501        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
10502
10503        // A withholding relay serves nothing — an empty fold raises no gap flag, and
10504        // the stored roster must be RETAINED, never wiped.
10505        let withholding = MemoryRelay::new();
10506        let _ = follow_control(&withholding, &community, &session).await;
10507        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10508        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
10509
10510        // A real revocation (a NEWER grant edition) does replace it.
10511        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10512        follow_control(&bed.relay, &community, &session).await.unwrap();
10513        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10514        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
10515    }
10516
10517    #[tokio::test]
10518    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
10519        let (bed, owner, member) = TestBed::new();
10520        bed.swap_to(&owner);
10521        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
10522
10523        bed.swap_to(&member);
10524        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
10525        assert!(err.contains("owner"), "refused before any publish: {err}");
10526
10527        // The deterministic admin-role entity stays unsquatted — the owner's later
10528        // legitimate mint is version 1 and folds cleanly.
10529        bed.swap_to(&owner);
10530        let view = fetch_authority(&bed.relay, &community).await;
10531        assert!(view.roles.roles.is_empty(), "no role edition landed");
10532        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
10533        let view = fetch_authority(&bed.relay, &community).await;
10534        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
10535    }
10536
10537    #[tokio::test]
10538    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
10539        let (bed, owner, member) = TestBed::new();
10540        bed.swap_to(&owner);
10541        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
10542        let member_pk = member.keys.public_key();
10543
10544        // The member already holds a Mod role, granted through the real send path
10545        // (so this device's floors track both entities).
10546        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
10547        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
10548        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
10549
10550        // A relay that withholds the control plane must refuse the merge — a blind
10551        // push would erase the Mod role at a higher version.
10552        let withholding = MemoryRelay::new();
10553        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
10554        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
10555
10556        // Against the full relay the merge preserves the Mod role.
10557        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
10558        let view = fetch_authority(&bed.relay, &community).await;
10559        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
10560        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
10561        assert!(grant.role_ids.contains(&mod_rid));
10562    }
10563
10564    #[tokio::test]
10565    async fn fetch_authority_reflects_a_granted_admin() {
10566        let (bed, owner, member) = TestBed::new();
10567        bed.swap_to(&owner);
10568        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
10569        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
10570        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10571        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
10572
10573        let view = fetch_authority(&bed.relay, &community).await;
10574        let member_hex = member.keys.public_key().to_hex();
10575        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
10576        assert!(
10577            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
10578            "an ADMIN_ALL grant carries KICK"
10579        );
10580        assert!(view.banned.is_empty());
10581    }
10582}