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::{Event, Keys, PublicKey, Timestamp};
15
16use super::super::transport::{Query, Transport};
17use super::super::{version, ChannelId, Epoch};
18use super::chat::{self, ChatEvent};
19use super::community::{ChannelV2, CommunityV2};
20use super::control;
21use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
22use super::invite::{self, CommunityInvite};
23use super::rekey::{self, Continuity, RekeyScope};
24use super::{guestbook, stream, vsk};
25use crate::community::edition::ParsedEdition;
26use crate::state::SessionGuard;
27
28/// The active signer for v2 authority actions: the live client's signer — which
29/// covers a NIP-46 bunker / NIP-55 offline signer — falling back to the local
30/// vault keys when there is no client or no signer attached (local accounts,
31/// headless/CLI paths, and tests). Every v2 seal, rekey blob, and control edition
32/// signs / NIP-44-wraps through this, so a keyless account can create AND
33/// administer a community. v2's rekey locator is public + its blobs are pairwise
34/// NIP-44 (CORD-06 D1/D5), so unlike v1 there is no raw-ECDH exception.
35/// The active identity's public key for addressing/tags — authoritative (set at
36/// login), no signer round-trip. Used everywhere v2 needs "who am I" so a keyless
37/// account (empty vault) still resolves its own identity.
38fn me_pk() -> Result<PublicKey, String> {
39    crate::state::my_public_key().ok_or_else(|| "no active identity".to_string())
40}
41
42fn now_ms() -> u64 {
43    std::time::SystemTime::now()
44        .duration_since(std::time::UNIX_EPOCH)
45        .map(|d| d.as_millis() as u64)
46        .unwrap_or(0)
47}
48
49/// Create a fresh v2 community owned by the local identity: mint the genesis
50/// (self-certifying id + the two owner editions), persist, publish the genesis
51/// control editions, and announce the owner's Guestbook Join. Returns the saved
52/// community.
53pub async fn create_community<T: Transport + ?Sized>(
54    transport: &T,
55    name: &str,
56    relays: Vec<String>,
57    description: Option<String>,
58) -> Result<CommunityV2, String> {
59    let session = SessionGuard::capture();
60    let signer = crate::signer::active_signer()?;
61    let owner_pk = me_pk()?;
62    let at_ms = now_ms();
63
64    let meta = control::CommunityMetadata {
65        name: name.to_string(),
66        description: description.clone(),
67        relays: relays.clone(),
68        ..Default::default()
69    };
70    let genesis = control::genesis_signed(owner_pk, &signer, meta, at_ms / 1000).await.map_err(|e| e.to_string())?;
71    let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
72
73    // Save-before-publish (like v1 create): no peers exist yet so there's no
74    // shared view to diverge from, and the fresh-random keys are irrecoverable
75    // if a publish hiccup rolled them back. Re-check the session after the genesis
76    // signing await (a bunker signs over the network) before the DB write.
77    if !session.is_valid() {
78        return Err("account changed during community creation".to_string());
79    }
80    // Seed the genesis edition heads (v1) as the owner's refuse-downgrade floor, so a
81    // later edit can't be rolled back by a relay serving only the genesis prefix. The
82    // live control sub is replay-free (limit 0), so the owner won't re-fold its own
83    // genesis to seed the floor otherwise. Floors land BEFORE the community row
84    // (floors-then-state ordering).
85    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
86    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
87    for wrap in &genesis.wraps {
88        if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
89            let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
90            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
91        }
92    }
93    crate::db::community::save_community_v2(&community)?;
94    // Archive the genesis root at epoch 0, so a later Refounding leaves this epoch's
95    // Public-channel history readable (CORD-03 §3 multi-epoch read).
96    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
97
98    // Publish the two genesis control editions at the epoch-0 control plane.
99    // Durable, not single-shot: over a slow transport (Tor) one attempt is a coin
100    // flip, and a lost genesis leaves a community that exists only locally. Durable
101    // races every relay, returns on the first ACK, then heals stragglers in the bg.
102    for wrap in &genesis.wraps {
103        transport.publish_durable(wrap, &community.relays).await?;
104    }
105
106    // Announce the owner's Guestbook Join so they appear in the memberlist. Relays are
107    // proven-alive by the genesis ACK above, so durable here just guarantees the owner's
108    // own join lands (member count) without a real block risk.
109    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
110    let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
111    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
112        let _ = transport.publish_durable(&join_wrap, &community.relays).await;
113    }
114
115    // Sync the new membership across devices (CORD-02 §8), durably — see the join path.
116    match republish_community_list(transport, Some(community.id())).await {
117        Ok(true) => {}
118        Ok(false) => republish_community_list_durable(Some(*community.id())),
119        Err(e) => {
120            crate::log_warn!("[CommunityList] failed to record this community across devices ({}) — retrying", e);
121            republish_community_list_durable(Some(*community.id()));
122        }
123    }
124    Ok(community)
125}
126
127/// Mint a v2 migration TWIN whose primary channel REUSES the v1 primary channel id (§migration)
128/// so chat history stitches through the flip. Same owner identity, fresh salt/root. Additional
129/// v1 channels are added by the wizard via `create_*_channel_with_id`. Mirrors
130/// [`create_community`]'s persist-before-publish + floor seeding.
131pub async fn create_migration_twin<T: Transport + ?Sized>(
132    transport: &T,
133    name: &str,
134    relays: Vec<String>,
135    description: Option<String>,
136    primary: (ChannelId, String),
137) -> Result<CommunityV2, String> {
138    let session = SessionGuard::capture();
139    let signer = crate::signer::active_signer()?;
140    let owner_pk = me_pk()?;
141    let at_ms = now_ms();
142
143    let meta = control::CommunityMetadata {
144        name: name.to_string(),
145        description: description.clone(),
146        relays: relays.clone(),
147        ..Default::default()
148    };
149    let primary_name = primary.1.clone();
150    let genesis = control::genesis_signed_with_primary(owner_pk, &signer, meta, at_ms / 1000, Some(primary))
151        .await
152        .map_err(|e| e.to_string())?;
153    let mut community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
154    // from_genesis hard-names the primary "general"; carry the v1 name (the wire edition
155    // already carries it, so this only keeps the owner's immediate local view correct).
156    if let Some(ch) = community.channels.first_mut() {
157        ch.name = primary_name;
158    }
159    if !session.is_valid() {
160        return Err("account changed during twin creation".to_string());
161    }
162    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
163    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
164    for wrap in &genesis.wraps {
165        if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
166            let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
167            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
168        }
169    }
170    crate::db::community::save_community_v2(&community)?;
171    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
172    for wrap in &genesis.wraps {
173        transport.publish_durable(wrap, &community.relays).await?;
174    }
175    // Owner Guestbook Join so they appear in the twin's memberlist.
176    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
177    let join_rumor = guestbook::build_join_rumor(owner_pk, None, at_ms);
178    if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, owner_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
179        let _ = transport.publish_durable(&join_wrap, &community.relays).await;
180    }
181    Ok(community)
182}
183
184/// Clone a v1 banlist onto the v2 twin (§migration Phase 1.3): the join-time ban gate needs
185/// the v2 banlist to name every v1-banned npub, else a banned-but-never-cut member who can
186/// open `m` would walk in. Owner-signed on the twin's control plane.
187pub async fn clone_banlist_to_twin<T: Transport + ?Sized>(
188    transport: &T,
189    twin: &CommunityV2,
190    banned: &[String],
191) -> Result<(), String> {
192    if banned.is_empty() {
193        return Ok(());
194    }
195    set_banlist(transport, twin, banned).await
196}
197
198/// Clone v1 governance onto the twin (§migration Phase 1.3): every v1 member who was a FULL
199/// admin (effective permissions ⊇ ADMIN_ALL) is re-granted @admin on the twin (mapping v1's
200/// Admin onto v2's deterministic admin role id, CORD-04 §2). The owner is supreme by
201/// identity (never a grant) and banned members are skipped (a banned author's editions fold
202/// out anyway, and re-granting would spring them back to admin on a future unban).
203///
204/// NON-ESCALATION: only a full admin maps to v2 @admin (which holds ADMIN_ALL). A
205/// partial-management v1 role holder (e.g. CREATE_INVITE only — never minted by the v1 UI,
206/// but reachable via the SDK) degrades to a plain member rather than being ESCALATED to full
207/// admin. Bespoke non-admin custom roles are not carried — a documented, non-security gap.
208pub async fn clone_governance_to_twin<T: Transport + ?Sized>(
209    transport: &T,
210    twin: &CommunityV2,
211    v1_roles: &crate::community::roles::CommunityRoles,
212    banned: &[String],
213) -> Result<(), String> {
214    use crate::community::roles::Permissions;
215    let owner = twin.owner()?;
216    for grant in &v1_roles.grants {
217        if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_ALL) {
218            continue; // not a full admin → plain member on v2 (never escalated)
219        }
220        if banned.contains(&grant.member) {
221            continue; // banned → no authority on v2, don't re-arm a future unban
222        }
223        let Ok(member) = PublicKey::parse(&grant.member) else { continue };
224        if member == owner {
225            continue; // supreme by identity — never needs a grant
226        }
227        grant_admin(transport, twin, &member).await?;
228    }
229    Ok(())
230}
231
232/// The twin's JoinMaterial — the membership subset sealed into the migration `m`.
233pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
234    join_material(twin)
235}
236
237/// Send a text message to a channel. Derives the channel's Chat-Plane group key
238/// (community_root for a Public channel, the channel key for a Private one),
239/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
240pub async fn send_message<T: Transport + ?Sized>(
241    transport: &T,
242    community: &CommunityV2,
243    channel_id: &ChannelId,
244    content: &str,
245) -> Result<String, String> {
246    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
247}
248
249/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
250/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
251/// attachments). Returns the message's rumor id (hex).
252pub async fn send_chat_message<T: Transport + ?Sized>(
253    transport: &T,
254    community: &CommunityV2,
255    channel_id: &ChannelId,
256    content: &str,
257    reply_to: Option<(&str, &str)>,
258    emoji: &[(&str, &str)],
259    extra_tags: Vec<nostr_sdk::prelude::Tag>,
260) -> Result<String, String> {
261    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
262}
263
264/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
265/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
266/// its optimistic pending row — the in-process echo and the finalize then key
267/// the SAME id (the exact v1 pending → sent contract).
268#[allow(clippy::too_many_arguments)]
269pub async fn send_chat_message_at<T: Transport + ?Sized>(
270    transport: &T,
271    community: &CommunityV2,
272    channel_id: &ChannelId,
273    content: &str,
274    reply_to: Option<(&str, &str)>,
275    emoji: &[(&str, &str)],
276    extra_tags: Vec<nostr_sdk::prelude::Tag>,
277    at_ms: u64,
278) -> Result<String, String> {
279    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
280    let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
281    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
282}
283
284/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
285/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
286/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
287/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
288#[allow(clippy::too_many_arguments)]
289pub async fn send_reaction<T: Transport + ?Sized>(
290    transport: &T,
291    community: &CommunityV2,
292    channel_id: &ChannelId,
293    target_id_hex: &str,
294    target_author_hex: &str,
295    target_kind: u16,
296    emoji_content: &str,
297    emoji: Option<(&str, &str)>,
298) -> Result<String, String> {
299    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
300    let at_ms = now_ms();
301    let rumor =
302        chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
303    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
304}
305
306/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
307/// with the replacement text. Author-enforced on the read side — only the
308/// original author's edit folds.
309pub async fn send_edit<T: Transport + ?Sized>(
310    transport: &T,
311    community: &CommunityV2,
312    channel_id: &ChannelId,
313    target_id_hex: &str,
314    new_content: &str,
315) -> Result<String, String> {
316    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
317    let at_ms = now_ms();
318    let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
319    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
320}
321
322/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
323/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
324/// retained per-message stream key (see `publish_chat`).
325pub async fn send_delete<T: Transport + ?Sized>(
326    transport: &T,
327    community: &CommunityV2,
328    channel_id: &ChannelId,
329    target_id_hex: &str,
330    target_kind: u16,
331) -> Result<String, String> {
332    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
333    let at_ms = now_ms();
334    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
335    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
336}
337
338/// Moderation-hide: remove SOMEONE ELSE's message under `MANAGE_MESSAGES`
339/// (CORD-04 §3/§5). Same kind-5 the author's own delete uses — CORD defines no
340/// separate hide, the authority is what differs, and every reader re-derives it
341/// from the seal's real npub against the folded Roster.
342///
343/// Gated locally against the same predicate peers enforce, so the button can't
344/// promise what the plane will refuse; a non-owner cites the Grant it acts under.
345/// `target_author` comes from the caller's resident copy — you can only moderate
346/// a message you can see.
347pub async fn moderation_delete<T: Transport + ?Sized>(
348    transport: &T,
349    community: &CommunityV2,
350    channel_id: &ChannelId,
351    target_id_hex: &str,
352    target_kind: u16,
353    target_author: &PublicKey,
354) -> Result<String, String> {
355    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
356    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
357    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
358        return Err("this community is dissolved — it accepts no new moderation actions".to_string());
359    }
360    let owner_hex = community.owner()?.to_hex();
361    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
362    if !crate::community::moderation::can_hide(
363        Some(&owner_hex),
364        &roster,
365        &author_pk.to_hex(),
366        &target_author.to_hex(),
367    ) {
368        return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
369    }
370    let at_ms = now_ms();
371    let citation = required_authority_citation(community, &author_pk)?;
372    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
373    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
374}
375
376/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
377/// `publish_webxdc_signal`: the same shared content shape, sealed on the
378/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
379/// Signed by the member's real identity — a member can't forge another
380/// player's presence. Failure is non-fatal to callers (the next re-advertise
381/// covers a missed ad).
382pub async fn send_webxdc_signal<T: Transport + ?Sized>(
383    transport: &T,
384    community: &CommunityV2,
385    channel_id: &ChannelId,
386    topic_id: &str,
387    node_addr: Option<&str>,
388) -> Result<(), String> {
389    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
390    let at_ms = now_ms();
391    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
392    let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
393    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
394}
395
396/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
397pub async fn send_typing<T: Transport + ?Sized>(
398    transport: &T,
399    community: &CommunityV2,
400    channel_id: &ChannelId,
401) -> Result<(), String> {
402    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
403    let at_ms = now_ms();
404    let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
405    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
406}
407
408/// Everything a chat-plane send needs: local keys, the channel's group key +
409/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
410/// community (every honest member sealed it read-only) and a keyless Private
411/// channel — deriving from the root would post to the public plane; its key
412/// arrives over the rekey plane.
413fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
414    let session = SessionGuard::capture();
415    let author_pk = me_pk()?;
416    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
417    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
418        return Err("this community has been dissolved".to_string());
419    }
420    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
421    // echo would silently no-op, so fail loudly instead of a message that seems
422    // to send but shows up nowhere.
423    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
424        return Err("you are banned from this community".to_string());
425    }
426    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
427    if ch.private && ch.key.is_none() {
428        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
429    }
430    let (secret, epoch) = community.channel_secret(ch);
431    Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
432}
433
434/// Seal one chat rumor, re-check the session, publish, and echo the send into the
435/// shared store. Returns the rumor id (hex).
436#[allow(clippy::too_many_arguments)]
437async fn publish_chat<T: Transport + ?Sized>(
438    transport: &T,
439    community: &CommunityV2,
440    session: &SessionGuard,
441    group: &GroupKey,
442    author_pk: PublicKey,
443    channel_id: &ChannelId,
444    epoch: Epoch,
445    rumor: nostr_sdk::prelude::UnsignedEvent,
446    at_ms: u64,
447    ephemeral: bool,
448) -> Result<String, String> {
449    let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
450    let signer = crate::signer::active_signer()?;
451    let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
452        .map_err(|e| e.to_string())?;
453    if !session.is_valid() {
454        return Err("account changed before send".to_string());
455    }
456    transport.publish(&wrap, &community.relays).await?;
457    // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
458    // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
459    // everywhere — the discarded p-tag pair only works on recipient-delete relays).
460    // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
461    // straddled network I/O.
462    if !ephemeral {
463        if !session.is_valid() {
464            return Ok(rumor_id);
465        }
466        crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
467    }
468    // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
469    // send-then-read works with no listen loop, and the relay's re-delivery dedups
470    // against this row instead of re-firing callbacks. Best-effort — the publish
471    // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
472    if !ephemeral {
473        if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
474            let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
475            let outcome = {
476                let mut st = crate::state::STATE.lock().await;
477                if !session.is_valid() {
478                    return Ok(rumor_id); // swapped on the lock await — never echo into another account.
479                }
480                super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
481            };
482            if let Some(outcome) = outcome {
483                if !session.is_valid() {
484                    return Ok(rumor_id);
485                }
486                super::inbound::persist_chat(&channel_hex, &outcome).await;
487            }
488        }
489    }
490    Ok(rumor_id)
491}
492
493/// A chat event opened from a channel fetch, tagged with the epoch its key
494/// decrypted under.
495pub struct FetchedEvent {
496    pub event: ChatEvent,
497    pub epoch: Epoch,
498}
499
500/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
501/// pre-retention and other-device sends stay fully deletable, because the wrap's
502/// signing key is the derivable group stream key — only this rumor→wrap mapping
503/// was ever missing locally. No-op for foreign authors, kinds the UI can't
504/// delete, and already-retained rows. Best-effort: a store failure never breaks
505/// the fetch.
506fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
507    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
508        return;
509    }
510    let opened = event.opened();
511    if crate::state::my_public_key() != Some(opened.author) {
512        return;
513    }
514    let rumor_hex = opened.rumor_id.to_hex();
515    // Only fill a confirmed gap — never clobber a send-time row, never write
516    // when the store can't be read.
517    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
518        return;
519    }
520    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
521        // The UI caches full-vs-limited delete verdicts per message; tell it this
522        // one just flipped so it re-resolves without an app restart.
523        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
524    }
525}
526
527/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
528/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
529/// per epoch; deeper history pages backwards via the walk.
530pub async fn fetch_channel<T: Transport + ?Sized>(
531    transport: &T,
532    community: &CommunityV2,
533    channel_id: &ChannelId,
534    limit: usize,
535) -> Result<Vec<FetchedEvent>, String> {
536    fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
537}
538
539/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
540/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
541/// address one `page`-sized query at a time until `max_pages`, a drained relay,
542/// or `keep_paging` returns false for a page (the caller's "I already hold
543/// these" early stop — consulted only on pages that opened something, so junk
544/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
545/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
546/// full page of only-already-seen wraps is a same-second WALL (relay filters
547/// are second-granular) and steps past it accepting that unseen same-second
548/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
549/// limitation (the `ms` tag can't be filtered server-side).
550///
551/// Returns everything opened, deduped by rumor id, oldest→newest.
552pub async fn fetch_channel_history<T: Transport + ?Sized>(
553    transport: &T,
554    community: &CommunityV2,
555    channel_id: &ChannelId,
556    page: usize,
557    max_pages: usize,
558    since: Option<u64>,
559    // Unix-seconds upper bound for the FIRST page (inclusive) — the back-paging
560    // cursor. `None` starts at the newest.
561    start_until: Option<u64>,
562    evidence: crate::community::transport::Evidence,
563    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
564) -> Result<Vec<FetchedEvent>, String> {
565    // Guards the opportunistic scrub-key heals below — the fetch loop straddles
566    // network I/O, and an account swap must not write into the new account's DB.
567    let session = SessionGuard::capture();
568    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
569    // A Public channel reads across EVERY held base-root epoch, and a Private one
570    // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
571    // continuous either way. A keyless Private channel is unreadable — never derived
572    // from the root (that would address the public plane).
573    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
574    let coords: Vec<([u8; 32], Epoch)> = if ch.private {
575        let Some(current) = ch.key else {
576            return Ok(Vec::new());
577        };
578        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
579        let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
580        if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
581            held.push((ch.epoch, current));
582        }
583        // Only real grants are archived, but keep the invariant local: a private
584        // plane is never read with the root value.
585        held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
586    } else {
587        let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
588        if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
589            roots.push((community.root_epoch, community.community_root));
590        }
591        roots.into_iter().map(|(ep, root)| (root, ep)).collect()
592    };
593    if coords.is_empty() {
594        return Ok(Vec::new());
595    }
596
597    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
598    let mut seen_rumors = std::collections::HashSet::new();
599    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
600    let mut until: Option<u64> = start_until;
601    let mut oldest: Option<u64> = None;
602    for _ in 0..max_pages {
603        // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
604        // relays (Ditto) require the connection authed as the author queried and
605        // reject a multi-author REQ ("all authors must be authenticated"), so a
606        // single merged fetch returns nothing there — the latest messages under a
607        // freshly-adopted epoch never load. Per-plane authed fetches + union.
608        let mut wraps: Vec<Event> = Vec::new();
609        let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
610        for (secret, epoch) in &coords {
611            let plane = channel_group_key(secret, channel_id, *epoch);
612            let q = Query {
613                kinds: vec![stream::KIND_WRAP],
614                authors: vec![plane.pk_hex()],
615                since,
616                until,
617                limit: Some(page),
618                evidence,
619                ..Default::default()
620            };
621            if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
622                for e in evs {
623                    if wrap_ids.insert(e.id) {
624                        wraps.push(e);
625                    }
626                }
627            }
628        }
629        if wraps.is_empty() {
630            break;
631        }
632        let mut fresh = 0usize;
633        let mut page_events: Vec<FetchedEvent> = Vec::new();
634        for wrap in &wraps {
635            if !seen_wraps.insert(wrap.id) {
636                continue;
637            }
638            fresh += 1;
639            let at = wrap.created_at.as_secs();
640            if oldest.is_none_or(|o| at < o) {
641                oldest = Some(at);
642            }
643            // Select the epoch whose group key authored this wrap (no trial decrypt).
644            for (secret, epoch) in &coords {
645                let group = channel_group_key(secret, channel_id, *epoch);
646                if wrap.pubkey != group.pk() {
647                    continue;
648                }
649                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
650                    let id = event.opened().rumor_id;
651                    if seen_rumors.insert(id) {
652                        if session.is_valid() {
653                            heal_own_wrap_key(&event, &group, &community.relays);
654                        }
655                        page_events.push(FetchedEvent { event, epoch: *epoch });
656                    }
657                }
658                break;
659            }
660        }
661        if fresh == 0 {
662            if wraps.len() < page {
663                break; // drained — the relay has nothing older.
664            }
665            // A full page of already-seen wraps: a same-second WALL. Step past it;
666            // same-second siblings beyond the relay's cap are unreachable by a
667            // second-granular filter.
668            let Some(o) = oldest else { break };
669            if o == 0 {
670                break;
671            }
672            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
673            until = Some(o - 1);
674            continue;
675        }
676        let stop = !page_events.is_empty() && !keep_paging(&page_events);
677        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
678        if stop {
679            break; // the caller holds everything from here back.
680        }
681        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
682    }
683    out.sort_by_key(|(ms, _)| *ms);
684    Ok(out.into_iter().map(|(_, e)| e).collect())
685}
686
687// ── Invites (CORD-05) ────────────────────────────────────────────────────────
688
689/// Who an invite bundle is FOR — which decides the Private-Channel keys it may
690/// carry (CORD-05 §1 vs §2).
691///
692/// A **Link** has no recipient: "anyone the link reaches can join", so its
693/// audience holds no Role by construction and is entitled to no Private Channel
694/// at all. A **Member** is a specific npub whose entitlement is computable.
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum BundleAudience {
697    /// A public link (33301 bundle event): public channels only.
698    Link,
699    /// A direct invite (3313) to this npub: may carry Private-Channel keys.
700    Member(PublicKey),
701}
702
703/// Build the §1 invite bundle for this community, scoped to `audience`. A
704/// Public channel carries the `community_root` as its "key" (the joiner derives
705/// the real secret from the root); a Private one its own key — and only for a
706/// Member the folded roster shows entitled. The bundle self-certifies the owner,
707/// so the inviter's identity is irrelevant to trust.
708pub fn bundle_of(
709    community: &CommunityV2,
710    audience: BundleAudience,
711    creator: Option<PublicKey>,
712    expires_at_ms: Option<u64>,
713    label: Option<String>,
714) -> CommunityInvite {
715    bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
716}
717
718/// [`bundle_of`] settling entitlement against a Grant this client JUST published
719/// (`with`/`without` role ids), since the fold lags its own publish. This is the
720/// grant-vend path (CORD-03 "delivered on grant").
721pub fn bundle_of_with_overlay(
722    community: &CommunityV2,
723    audience: BundleAudience,
724    creator: Option<PublicKey>,
725    expires_at_ms: Option<u64>,
726    label: Option<String>,
727    with: &[String],
728    without: &[String],
729) -> CommunityInvite {
730    let hex = crate::simd::hex::bytes_to_hex_32;
731    let cid_hex = hex(&community.identity.community_id.0);
732    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
733    let owner_hex = community.owner().ok().map(|o| o.to_hex());
734    let recipient_hex = match audience {
735        BundleAudience::Link => None,
736        BundleAudience::Member(pk) => Some(pk.to_hex()),
737    };
738    let channels = community
739        .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
740        .into_iter()
741        .map(|c| invite::ChannelGrant {
742            id: hex(&c.id.0),
743            key: hex(&c.key.unwrap_or(community.community_root)),
744            epoch: c.epoch.0,
745            name: c.name.clone(),
746        })
747        .collect();
748    CommunityInvite {
749        community_id: hex(&community.identity.community_id.0),
750        owner: hex(&community.identity.owner_xonly),
751        owner_salt: hex(&community.identity.owner_salt),
752        community_root: hex(&community.community_root),
753        root_epoch: community.root_epoch.0,
754        channels,
755        relays: community.relays.clone(),
756        name: community.name.clone(),
757        // Mint-time snapshot so a parked invite renders the real logo before any
758        // fold; the Control Plane stays the authority after joining.
759        icon: community.icon.clone(),
760        expires_at: expires_at_ms,
761        creator_npub: creator.map(|p| p.to_hex()),
762        label,
763        extra: Default::default(),
764    }
765}
766
767/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
768/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
769/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
770/// bundle hands over the keys; the recipient consents by accepting (nothing joins
771/// on receipt). Returns the wrap.
772pub async fn send_direct_invite<T: Transport + ?Sized>(
773    transport: &T,
774    community: &CommunityV2,
775    recipient: &PublicKey,
776    expires_at_ms: Option<u64>,
777    label: Option<String>,
778) -> Result<Event, String> {
779    let session = SessionGuard::capture();
780    // A stale bundle is worse than a stale edit: it hands the joiner keys to a
781    // buried epoch, and their client later self-evicts on the rekey exclusion.
782    assert_current_root(community)?;
783    let signer = crate::signer::active_signer()?;
784    let inviter_pk = me_pk()?;
785    let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
786    let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
787    if !session.is_valid() {
788        return Err("account changed before sending invite".to_string());
789    }
790    transport.publish(&wrap, &community.relays).await?;
791    Ok(wrap)
792}
793
794/// A minted public link: the shareable URL plus the addressable bundle event to
795/// publish and the link keypair to retain (in the Invite List) for later refresh
796/// or revocation.
797pub struct MintedLink {
798    pub url: String,
799    pub bundle_event: Event,
800    pub link_signer: Keys,
801    pub token: [u8; super::derive::TOKEN_LEN],
802    /// Unix ms, mirrored from the bundle. The Invite List is the creator's only
803    /// record of it, and the Registry prunes on it — the coordinate a member
804    /// folds carries no expiry, so a lapsed link the creator never pruned reads
805    /// as a live door forever (CORD-05 §4/§5).
806    pub expires_at_ms: Option<u64>,
807    pub label: Option<String>,
808}
809
810/// Mint a public invite link for this community: a fresh token + link keypair, the
811/// bundle encrypted under the token key and published at `(33301, link_signer,
812/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
813/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
814/// relays and never reaches a server.
815pub async fn mint_public_link<T: Transport + ?Sized>(
816    transport: &T,
817    community: &CommunityV2,
818    base: &str,
819    expires_at_ms: Option<u64>,
820    label: Option<String>,
821) -> Result<MintedLink, String> {
822    let session = SessionGuard::capture();
823    let mut token = [0u8; super::derive::TOKEN_LEN];
824    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
825    let link_signer = Keys::generate();
826    let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
827    let bundle_key = super::derive::invite_bundle_key(&token);
828    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
829    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
830
831    if !session.is_valid() {
832        return Err("account changed before minting link".to_string());
833    }
834    transport.publish_durable(&bundle_event, &community.relays).await?;
835    let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
836    // Sync the link across the creator's devices (13303) + publish the Registry
837    // (vsk-8) so members see the community is Public. Best-effort — the link works
838    // without the sync.
839    let _ = record_minted_link(transport, community, &minted).await;
840    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
841    // the 13303 list remains the cross-device record. Re-check the session: the
842    // publishes above straddled awaits, and this write must not land account A's
843    // link (secret token included) in a swapped-in account's DB.
844    if session.is_valid() {
845        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
846        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
847        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
848    }
849    Ok(minted)
850}
851
852// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
853
854/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
855/// decrypt/parse failure is "no news", never a clobber of the local mirror).
856/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
857/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
858/// fresh one wipes every link minted on other devices. Full evidence for the
859/// same reason — this read feeds replaceable-event writes.
860async fn fetch_invite_list<T: Transport + ?Sized>(
861    transport: &T,
862    relays: &[String],
863) -> Result<Option<invite::InviteList>, String> {
864    let signer = crate::signer::active_signer()?;
865    let my_pk = me_pk()?;
866    let query = Query {
867        kinds: vec![super::kind::INVITE_LIST],
868        authors: vec![my_pk.to_hex()],
869        limit: Some(4),
870        evidence: crate::community::transport::Evidence::Full,
871        ..Default::default()
872    };
873    let events = transport.fetch(&query, relays).await?;
874    let mut best: Option<(u64, invite::InviteList)> = None;
875    for e in events {
876        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
877            let at = e.created_at.as_secs();
878            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
879                best = Some((at, l));
880            }
881        }
882    }
883    Ok(best.map(|(_, l)| l))
884}
885
886/// The creator's LIVE link-signer pubkeys for one community — the Registry's
887/// content (CORD-05 §5), derived from the stored link secrets.
888///
889/// Live means neither tombstoned nor EXPIRED. An expired link cannot be joined
890/// (`InviteBundle::expired`, CORD-05 §1), so leaving it in the Registry states
891/// a door that isn't there: the aggregate never empties, the community reads
892/// Public forever, and every gate hanging off that reading silently inverts.
893fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
894    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
895    list.entries
896        .iter()
897        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
898        .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
899        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
900        .collect()
901}
902
903/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
904/// community — so members fold it into the Public/Private source of truth (a
905/// non-empty aggregate = Public).
906async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
907    let my_pk = me_pk()?;
908    let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
909    let content = invite::build_registry_content(live_signers);
910    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
911    // Refresh the cache from the PLANE, not from `live_signers`: the column aggregates
912    // every creator, so writing only mine would clobber theirs, and a union could never
913    // shrink — retiring the last link would leave the community reading Public forever.
914    refresh_invite_registry_cache(transport, community, session).await;
915    Ok(())
916}
917
918/// Re-fold the whole invite Registry and cache it, so Public/Private stays a sync
919/// LOCAL read. Silent no-op when the plane can't be read whole — a partial fold
920/// would under-state Public, leaving a live link open behind a ban.
921async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
922    let Ok(owner) = community.owner() else { return };
923    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
924    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
925    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
926        .unwrap_or_default()
927        .into_iter()
928        .filter(|(_, f)| f.0 == community.root_epoch.0)
929        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
930        .collect();
931    let authority = fold_authority(community, &editions, &floors);
932    let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
933    if session.is_valid() {
934        let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
935        let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
936    }
937}
938
939/// Record a freshly-minted public link across the creator's devices: append it to the
940/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
941async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
942    let session = SessionGuard::capture();
943    let signer = crate::signer::active_signer()?;
944    let my_pk = me_pk()?;
945    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
946    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
947    // Err aborts the sync half (the link's bundle already published durably;
948    // a retry re-records it) — an unreachable relay set must never be mistaken
949    // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
950    // creator's honest first list.
951    let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
952    if !list.entries.iter().any(|e| e.token == token_hex) {
953        list.entries.push(invite::InviteEntry {
954            token: token_hex,
955            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
956            community_id: cid_hex.clone(),
957            url: minted.url.clone(),
958            label: minted.label.clone(),
959            created_at: now_ms() / 1000,
960            expires_at: minted.expires_at_ms,
961            extra: Default::default(),
962        });
963    }
964    if !session.is_valid() {
965        return Err("account changed during link record".to_string());
966    }
967    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
968    transport.publish(&event, &community.relays).await?;
969    let signers = live_signers_for(&list, &cid_hex, now_ms());
970    publish_invite_registry(transport, community, &session, &signers).await
971}
972
973/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
974/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
975/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
976/// LAST live link empties the Registry → the community reads Private (a Refounding is
977/// the owner's separate read-cut).
978pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
979    let session = SessionGuard::capture();
980    let signer = crate::signer::active_signer()?;
981    let my_pk = me_pk()?;
982    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
983    let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
984    let entry = list
985        .entries
986        .iter()
987        .find(|e| e.token == token_hex && e.community_id == cid_hex)
988        .cloned()
989        .ok_or("no such link in the invite list")?;
990    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
991    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
992    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
993    if !session.is_valid() {
994        return Err("account changed during revoke".to_string());
995    }
996    transport.publish_durable(&revocation, &community.relays).await?;
997    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
998    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
999    list.entries.retain(|e| e.token != token_hex);
1000    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1001    transport.publish(&event, &community.relays).await?;
1002    let signers = live_signers_for(&list, &cid_hex, now_ms());
1003    publish_invite_registry(transport, community, &session, &signers).await?;
1004    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
1005    if session.is_valid() {
1006        let _ = crate::db::community::delete_public_invite(token_hex);
1007    }
1008    Ok(())
1009}
1010
1011/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
1012/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
1013/// coordinate with the CURRENT community state, so a link shared once keeps working
1014/// across rotations. Best-effort.
1015pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1016    let session = SessionGuard::capture();
1017    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1018    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
1019    // Err — the caller (a post-refounding refresh) must be able to retry, or live
1020    // links keep serving the PRE-refound root and new joiners land on the dead
1021    // epoch. A genuinely-empty list is Ok (nothing to refresh).
1022    let signer = crate::signer::active_signer()?;
1023    let my_pk = me_pk()?;
1024    let query = Query {
1025        kinds: vec![super::kind::INVITE_LIST],
1026        authors: vec![my_pk.to_hex()],
1027        limit: Some(4),
1028        ..Default::default()
1029    };
1030    let events = transport.fetch(&query, &community.relays).await?;
1031    let mut best: Option<(u64, invite::InviteList)> = None;
1032    for e in events {
1033        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1034            let at = e.created_at.as_secs();
1035            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1036                best = Some((at, l));
1037            }
1038        }
1039    }
1040    let Some((_, list)) = best else {
1041        return Ok(());
1042    };
1043    let creator = my_pk;
1044    let now = now_ms();
1045    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1046    for entry in &list.entries {
1047        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1048            continue;
1049        }
1050        // An expired link can't be joined, so refreshing it just re-states a
1051        // door that isn't there (CORD-05 §1/§5).
1052        if entry.expires_at.is_some_and(|exp| now > exp) {
1053            continue;
1054        }
1055        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1056        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1057        let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1058        let bundle_key = super::derive::invite_bundle_key(&token);
1059        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1060            if !session.is_valid() {
1061                return Err("account changed during link refresh".to_string());
1062            }
1063            let _ = transport.publish_durable(&event, &community.relays).await;
1064        }
1065    }
1066    // Republish the Registry from the same pruned view. Expiry is the one way a
1067    // link dies with no user action, so without a heal point here the aggregate
1068    // never empties and the community reads Public long after its last door
1069    // shut (CORD-05 §5). Idempotent when nothing lapsed.
1070    //
1071    // Only for a creator who actually minted here: one Invite List spans every
1072    // community, so a member holding links ELSEWHERE would otherwise publish an
1073    // empty Registry edition into this one on every rotation they adopt — a
1074    // control-plane write, and a version bump, for a coordinate they never owned.
1075    let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1076    if !mine_here {
1077        return Ok(());
1078    }
1079    let signers = live_signers_for(&list, &cid_hex, now);
1080    if !session.is_valid() {
1081        return Err("account changed during link refresh".to_string());
1082    }
1083    let _ = publish_invite_registry(transport, community, &session, &signers).await;
1084    Ok(())
1085}
1086
1087/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1088/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1089/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1090/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1091pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1092    let Ok(owner) = community.owner() else { return false };
1093    // Truncation fails toward Public: over-stating it only makes a caller take the
1094    // stronger remedy (privatise + re-found + reissue), while under-stating it
1095    // leaves a live link open behind a ban.
1096    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1097    let cid = community.id();
1098    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1099    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1100        .unwrap_or_default()
1101        .into_iter()
1102        .filter(|(_, f)| f.0 == community.root_epoch.0)
1103        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1104        .collect();
1105    let authority = fold_authority(community, &editions, &floors);
1106    !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1107}
1108
1109/// Page the WHOLE control plane, not the newest window: a registry pushed out of a
1110/// single page reads as retired, and any member can push it out since the plane key
1111/// comes from the community root they hold. `None` = it could NOT be read whole
1112/// (transport failure, same-second wall, pager depth), so a caller must not mistake
1113/// an empty fold for absence.
1114async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1115    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1116    let mut editions: Vec<ParsedEdition> = Vec::new();
1117    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1118    let mut oldest: Option<u64> = None;
1119    let mut until: Option<u64> = None;
1120    for page in 0..COMPACT_MAX_PAGES {
1121        // Quorum, DECLARED (the until→Full transport floor is gone): these
1122        // control reads tolerate a partial union — their fold semantics are
1123        // fail-safe on gaps (seeded banlists, withheld roster cache).
1124        let query = Query {
1125            kinds: vec![stream::KIND_WRAP],
1126            authors: vec![control.pk_hex()],
1127            until,
1128            limit: Some(FOLLOW_PAGE),
1129            evidence: crate::community::transport::Evidence::Quorum,
1130            ..Default::default()
1131        };
1132        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1133        let mut fresh = 0usize;
1134        for w in &wraps {
1135            if !seen_wraps.insert(w.id) {
1136                continue;
1137            }
1138            fresh += 1;
1139            let at = w.created_at.as_secs();
1140            if oldest.is_none_or(|o| at < o) {
1141                oldest = Some(at);
1142            }
1143            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1144                editions.push(ed);
1145            }
1146        }
1147        if fresh == 0 {
1148            if wraps.len() >= FOLLOW_PAGE {
1149                return None; // same-second wall: the plane can't be read whole
1150            }
1151            return Some(editions);
1152        }
1153        until = oldest;
1154        if page + 1 == COMPACT_MAX_PAGES {
1155            return None;
1156        }
1157    }
1158    Some(editions)
1159}
1160
1161/// The live link coordinates PER AUTHORISED CREATOR across every Registry (vsk-8);
1162/// non-empty ⇒ the Community is Public, and the per-creator split is what drives
1163/// "X has N active invite links". Pure over an already-fetched edition set so the
1164/// on-demand probe and the control follow fold it identically.
1165fn live_invite_link_sets(
1166    cid: &crate::community::CommunityId,
1167    owner_hex: &str,
1168    editions: &[ParsedEdition],
1169    authority: &AuthoritySet,
1170    floors: &Floors,
1171) -> Vec<crate::db::community::InviteLinkSetRow> {
1172    use crate::community::roles::Permissions;
1173    use std::collections::BTreeMap;
1174    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1175    for e in editions {
1176        if e.vsk == vsk::INVITE_LINKS {
1177            by_eid.entry(e.entity_id).or_default().push(e);
1178        }
1179    }
1180    let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1181    for (eid, group) in &by_eid {
1182        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1183        // picks an equal-version winner author-blind (lowest inner id, which an
1184        // author can grind), so folding first would let any member occupy the head
1185        // slot and have the whole registry dropped by the check below — silently
1186        // retiring a live invite link, i.e. flipping the community to Private.
1187        let authed: Vec<&ParsedEdition> = group
1188            .iter()
1189            .copied()
1190            .filter(|p| {
1191                let author = p.author.to_hex();
1192                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1193                !authority.banned.contains(&author)
1194                    && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1195                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1196            })
1197            .collect();
1198        if authed.is_empty() {
1199            continue;
1200        }
1201        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1202        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1203        if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1204            if signers.is_empty() {
1205                continue; // a creator who retired every link is absent, not a zero row
1206            }
1207            sets.push(crate::db::community::InviteLinkSetRow {
1208                creator_hex: authed[hi].author.to_hex(),
1209                locators: signers.iter().map(|p| p.to_hex()).collect(),
1210            });
1211        }
1212    }
1213    sets
1214}
1215
1216/// Flatten per-creator sets into the aggregate the `invite_registry` column holds.
1217fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1218    let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1219    flat.sort();
1220    flat.dedup();
1221    flat
1222}
1223
1224/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1225/// delivered community_root is genuinely the owner's, persist the community, and
1226/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1227/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
1228/// caller did) so the `is_valid()` gate straddles that I/O.
1229async fn accept_bundle<T: Transport + ?Sized>(
1230    transport: &T,
1231    session: &SessionGuard,
1232    bundle: &CommunityInvite,
1233    invited_by: Option<PublicKey>,
1234    announce_join: bool,
1235) -> Result<CommunityV2, String> {
1236    let signer = crate::signer::active_signer()?;
1237    let my_pk = me_pk()?;
1238    let at_ms = now_ms();
1239    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1240    if bundle.expired(at_ms) {
1241        return Err("this invite has expired".to_string());
1242    }
1243    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1244    let community = CommunityV2::from_bundle(bundle, at_ms)?;
1245    // Captured before the save below: a re-accept of a held community must not
1246    // re-announce a membership this account already declared.
1247    let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1248
1249    // Authenticate the delivered community_root before trusting it. The owner
1250    // commitment proves WHO the owner is, but community_root (and channel keys) are
1251    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1252    // with an attacker-chosen root and silently partition the joiner onto planes
1253    // only the attacker controls. Requiring the owner's genesis to open under the
1254    // delivered root closes that eclipse; also reconciles channel classification.
1255    // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1256    // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1257    let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1258        v.session.is_valid()
1259            && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1260            && v.community_id == community.id().0
1261            && v.community_root == community.community_root
1262    });
1263    let (community, join_heads, join_banlist) = match handoff {
1264        Some(v) => {
1265            let mut c = v.folded;
1266            // The preview holds no acquisition time — stamp the JOIN's.
1267            c.created_at_ms = at_ms;
1268            (c, v.heads, v.banned)
1269        }
1270        None => verify_owner_root_and_reconcile(transport, community).await?,
1271    };
1272
1273    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1274    if is_dissolved(transport, &community).await {
1275        return Err("this community has been dissolved".to_string());
1276    }
1277
1278    // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1279    // community whose authorized banlist names it — before the Guestbook Join publishes
1280    // and before any local write. Every door funnels through here (direct invite, parked,
1281    // public link, migration), so none of them needs its own exclusion.
1282    if join_banlist.contains(&my_pk.to_hex()) {
1283        return Err("you are banned from this community".to_string());
1284    }
1285
1286    // The account must not have swapped since the guard was captured (which was
1287    // before any fetch the caller / the verify above performed) — else we'd write
1288    // A's join into B.
1289    if !session.is_valid() {
1290        return Err("account changed during join".to_string());
1291    }
1292    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1293    // community row lands (floors-then-state, so a mid-seed error can't leave saved
1294    // state outrunning its floor); the first post-join follow then can't persist a
1295    // state below what this join already showed.
1296    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1297    for h in &join_heads {
1298        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)?;
1299    }
1300    crate::db::community::save_community_v2(&community)?;
1301    // Archive the joined root at its epoch, so this member reads Public-channel
1302    // history from their join epoch onward across later Refoundings (CORD-03 §3).
1303    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1304    // Same for each granted Private-channel key: the archive is what lets its
1305    // history stay readable after the channel rotates away from this key.
1306    for ch in &community.channels {
1307        if let (true, Some(key)) = (ch.private, ch.key) {
1308            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1309        }
1310    }
1311
1312    // Announce our Guestbook Join, echoing the invite attribution when present.
1313    // Only an ACTUAL join speaks: a re-accept of a held community, or a
1314    // cross-device key sync (announce_join=false), is not a membership event —
1315    // the account's original Join already stands in the guestbook, and every
1316    // re-publish renders as "<user> has joined" spam for the whole community.
1317    if announce_join && !already_held {
1318        let attribution = invited_by
1319            .map(|p| p.to_hex())
1320            .or_else(|| bundle.creator_npub.clone())
1321            .zip(Some(bundle.label.clone().unwrap_or_default()));
1322        let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1323        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1324        let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1325        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1326            let _ = transport.publish(&join_wrap, &community.relays).await;
1327        }
1328    }
1329
1330    // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1331    // happy path; anything else hands off to the durable retry, because an unrecorded
1332    // join is what strands a community behind a stale tombstone.
1333    match republish_community_list(transport, Some(community.id())).await {
1334        Ok(true) => {}
1335        Ok(false) => republish_community_list_durable(Some(*community.id())),
1336        Err(e) => {
1337            crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1338            republish_community_list_durable(Some(*community.id()));
1339        }
1340    }
1341    Ok(community)
1342}
1343
1344/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1345/// channel classification from the owner's editions. `community_id` commits only
1346/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1347/// every synced Community List) — so a forged invite can present a real community's
1348/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1349/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1350/// owner commitment still "verifies". The defense: the owner's genesis metadata
1351/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1352/// attacker can't forge the owner's seal — so its presence on the control plane
1353/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1354/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1355/// re-wraps heads with their original signatures), so the anchor there is the
1356/// community-bound metadata head plus any owner-signed edition under the same root.
1357/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1358/// On success, folds the owner's authoritative editions to heal a bundle that
1359/// misclassified a channel.
1360async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1361    transport: &T,
1362    community: CommunityV2,
1363) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1364    let owner = community.owner()?;
1365    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1366    let control_pk = control.pk_hex();
1367
1368    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1369    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1370    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1371    // satisfy the gate and the control plane reads back empty. Register this
1372    // community's stream keys + start the challenge responder so the fetch below
1373    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1374    super::streamauth::prime(&community);
1375
1376    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1377    // community_id`) at the root-derived control plane. The genesis eid pins it to
1378    // THIS community, and it lives ONLY under the real root — so a forged root can't
1379    // produce one: an edition's seal carries no community binding, but another
1380    // community's genesis has a different eid, and this community's own genesis is
1381    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1382    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1383    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1384    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1385    // binding the root into community_id (protocol, deferred).
1386    //
1387    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1388    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1389    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1390    // an open partial window misses a genesis on a lagging relay (routine over
1391    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1392    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1393    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1394    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1395    // that buries the genesis past the walk is the deferred protocol residual.
1396    const PAGE: usize = 500;
1397    const MAX_PAGES: usize = 4;
1398    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1399    let mut editions: Vec<ParsedEdition> = Vec::new();
1400    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1401    let mut found_genesis = false;
1402    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1403    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1404    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1405    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1406    // at least one owner-signed edition opened under this root. A non-member forger
1407    // can produce neither; the sibling-community rewrap residual this reopens is the
1408    // same class the spec defers to root-in-id binding.
1409    let mut compacted_metadata = false;
1410    crate::log_debug!(
1411        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1412        &control_pk[..12], community.root_epoch, community.relays
1413    );
1414    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1415        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1416    };
1417    for attempt in 0..2 {
1418        editions.clear();
1419        all_editions.clear();
1420        compacted_metadata = false;
1421        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1422        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1423        for page_no in 0..MAX_PAGES {
1424            let query = Query {
1425                kinds: vec![stream::KIND_WRAP],
1426                authors: vec![control_pk.clone()],
1427                until,
1428                limit: Some(PAGE),
1429                evidence: crate::community::transport::Evidence::Full,
1430                ..Default::default()
1431            };
1432            let wraps = transport.fetch(&query, &community.relays).await?;
1433            crate::log_trace!(
1434                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1435                attempt, page_no, wraps.len()
1436            );
1437            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1438            // siblings at a page boundary (and the genesis with them); re-served
1439            // boundary events are free, and no-new-events means exhausted.
1440            let mut oldest = u64::MAX;
1441            let mut fresh = 0usize;
1442            for w in &wraps {
1443                if !seen_wraps.insert(w.id) {
1444                    continue;
1445                }
1446                fresh += 1;
1447                oldest = oldest.min(w.created_at.as_secs());
1448                if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1449                    crate::log_trace!(
1450                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1451                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1452                        ed.author == owner, w.created_at.as_secs()
1453                    );
1454                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1455                        if ed.author == owner {
1456                            found_genesis = true;
1457                        } else {
1458                            compacted_metadata = true;
1459                        }
1460                    }
1461                    if ed.author == owner {
1462                        editions.push(ed.clone());
1463                    }
1464                    // Any-author set for the join-time authority fold below: the banlist head
1465                    // may be admin-signed, and its authority chains to the owner regardless.
1466                    all_editions.push(ed);
1467                }
1468            }
1469            crate::log_debug!(
1470                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1471                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1472            );
1473            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1474                break; // authenticated, or the relay is exhausted.
1475            }
1476            until = Some(oldest);
1477        }
1478        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1479            break;
1480        }
1481        if attempt == 0 {
1482            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1483            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1484            // satisfy a stream-authors gate — and can land before the responder's
1485            // stream-key auth settles, reading the plane back EMPTY. Replay the
1486            // remembered challenges for every registered stream key, then walk once
1487            // more on the settled connection.
1488            if let Some(client) = crate::state::nostr_client() {
1489                super::streamauth::prime_auth(&client, &community.relays).await;
1490            }
1491        }
1492    }
1493    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1494        return Err(
1495            "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"
1496                .to_string(),
1497        );
1498    }
1499    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1500    // entity). The heads this fold verified are returned for the caller to SEED as
1501    // the initial floor once the community row is saved — without that, the first
1502    // post-join follow would bootstrap floor-less and could persist a state BELOW
1503    // what this join already verified and showed.
1504    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1505    // above), and the owner is supreme — so owner-only authority suffices. The full
1506    // roster (admins) folds on the first post-join follow_control.
1507    let empty_floors = Floors::new();
1508    let authority = AuthoritySet::owner_only();
1509    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1510    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1511    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1512    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1513    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1514    let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1515    Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1516}
1517
1518/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1519/// then run the shared accept path. The recipient's consent IS this call. No
1520/// network await precedes the accept, so the guard captured here suffices.
1521pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1522    let session = SessionGuard::capture();
1523    let signer = crate::signer::active_signer()?;
1524    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1525    accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1526}
1527
1528/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1529/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1530/// bundle validation, then runs the shared accept path (which re-verifies the owner
1531/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1532/// Join attribution.
1533pub async fn accept_parked_invite<T: Transport + ?Sized>(
1534    transport: &T,
1535    bundle_json: &str,
1536    inviter_hex: Option<&str>,
1537) -> Result<CommunityV2, String> {
1538    let session = SessionGuard::capture();
1539    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1540    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1541    accept_bundle(transport, &session, &bundle, invited_by, true).await
1542}
1543
1544/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1545/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1546/// path, which re-verifies the owner root over the network and enforces the join-time ban
1547/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1548/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1549pub async fn accept_migration_material<T: Transport + ?Sized>(
1550    transport: &T,
1551    jm: &super::list::JoinMaterial,
1552) -> Result<CommunityV2, String> {
1553    let session = SessionGuard::capture();
1554    let bundle = material_to_invite(jm);
1555    accept_bundle(transport, &session, &bundle, None, true).await
1556}
1557
1558/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1559/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1560/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1561/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1562/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1563/// the newest valid Live by `created_at`. Read-only.
1564pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1565    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1566    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1567    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1568    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1569    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1570    let query = Query {
1571        kinds: vec![super::kind::INVITE_BUNDLE],
1572        authors: vec![parsed.link_signer.to_hex()],
1573        ..Default::default()
1574    };
1575    let relays = if parsed.bootstrap_relays.is_empty() {
1576        invite::stock_relays()
1577    } else {
1578        parsed.bootstrap_relays.clone()
1579    };
1580    // One bounded retry: a join fired while the pool is still warming (bootstrap
1581    // relays mid-handshake, routine during boot contention) reads back a transport
1582    // error, not an absent bundle. The pool add already happened on the first try,
1583    // so wait for a socket rather than guessing with a fixed sleep.
1584    let events = match transport.fetch(&query, &relays).await {
1585        Ok(evs) => evs,
1586        Err(_) => {
1587            wait_for_bootstrap_relay(&relays).await;
1588            transport.fetch(&query, &relays).await?
1589        }
1590    };
1591    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1592
1593    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1594    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1595    for event in &events {
1596        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1597            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1598            Ok(invite::BundleState::Live(bundle)) => {
1599                let at = event.created_at.as_secs();
1600                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1601                    newest_live = Some((at, *bundle));
1602                }
1603            }
1604            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1605        }
1606    }
1607    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1608}
1609
1610/// Wait — bounded — for ANY of the targets to report Connected before a retry:
1611/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
1612/// handshake takes under boot contention.
1613async fn wait_for_bootstrap_relay(relays: &[String]) {
1614    let Some(client) = crate::state::nostr_client() else { return };
1615    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1616    loop {
1617        for url in relays {
1618            if let Ok(Some(relay)) = client.relay(url).await {
1619                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1620                    return;
1621                }
1622            }
1623        }
1624        if tokio::time::Instant::now() >= deadline {
1625            return;
1626        }
1627        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1628    }
1629}
1630
1631/// The most recent owner-root verification a PREVIEW completed, handed to a join
1632/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1633/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1634/// a different delivered root never matches. The join's own bundle re-fetch is
1635/// untouched, so the revocation gate always runs live.
1636struct VerifiedPreview {
1637    session: SessionGuard,
1638    at: std::time::Instant,
1639    community_id: [u8; 32],
1640    community_root: [u8; 32],
1641    folded: CommunityV2,
1642    heads: Vec<FoldedHead>,
1643    /// The join-time authorized banlist from the SAME verified walk — carried so the
1644    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
1645    banned: std::collections::BTreeSet<String>,
1646}
1647static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1648const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1649
1650/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1651/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1652/// carries no icon — the fold is the authority). Owner-root verification rides
1653/// the fold, so a forged-root link can't render a convincing preview; on a
1654/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1655/// — the caller hasn't joined.
1656pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1657    let bundle = fetch_public_bundle(transport, url).await?;
1658    preview_bundle(transport, &bundle).await
1659}
1660
1661/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1662/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1663/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1664/// it again regardless.
1665pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1666    let community = CommunityV2::from_bundle(bundle, 0)?;
1667    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1668        Ok((folded, heads, banned)) => {
1669            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1670                session: SessionGuard::capture(),
1671                at: std::time::Instant::now(),
1672                community_id: folded.id().0,
1673                community_root: folded.community_root,
1674                folded: folded.clone(),
1675                heads,
1676                banned,
1677            });
1678            Ok(folded)
1679        }
1680        Err(_) => Ok(community),
1681    }
1682}
1683
1684/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1685pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1686    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
1687    let session = SessionGuard::capture();
1688    let bundle = fetch_public_bundle(transport, url).await?;
1689    if !session.is_valid() {
1690        return Err("account changed during join".to_string());
1691    }
1692    accept_bundle(transport, &session, &bundle, None, true).await
1693}
1694
1695/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1696pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1697    let session = SessionGuard::capture();
1698    let signer = crate::signer::active_signer()?;
1699    let my_pk = me_pk()?;
1700    let at_ms = now_ms();
1701    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1702    let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1703    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1704        let _ = transport.publish(&wrap, &community.relays).await;
1705    }
1706    if !session.is_valid() {
1707        return Err("account changed during leave".to_string());
1708    }
1709    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1710    // to the leaving community's own relays (it's about to be gone locally) —
1711    // best-effort.
1712    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1713    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1714    if !session.is_valid() {
1715        return Err("account changed during leave".to_string());
1716    }
1717    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1718    Ok(())
1719}
1720
1721/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1722/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1723/// `can_kick`), so publishing without authority is inert. A kicked member may
1724/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1725pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1726    let session = SessionGuard::capture();
1727    assert_current_root(community)?;
1728    let signer = crate::signer::active_signer()?;
1729    let my_pk = me_pk()?;
1730    // Fast local pre-check; readers re-verify independently.
1731    let authority = fetch_authority(transport, community).await;
1732    let owner_hex = community.owner()?.to_hex();
1733    if !authority.roles.can_act_on_member(
1734        &my_pk.to_hex(),
1735        Some(&owner_hex),
1736        &target.to_hex(),
1737        crate::community::roles::Permissions::KICK,
1738    ) {
1739        return Err("not authorized to kick this member".to_string());
1740    }
1741    // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
1742    // first, so the target's rank is gone before the departure lands. Without it a
1743    // kicked admin leaves the memberlist still holding every management bit, and
1744    // every client keeps honoring their control editions.
1745    //
1746    // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
1747    // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
1748    // target just keeps their rank until an authorized strip lands. Each layer
1749    // validates on its own rule, so a missing one is a weaker removal, never a
1750    // broken one. A strip we DO attempt and lose is a hard error: proceeding would
1751    // publish a directive we know leaves rank behind.
1752    let target_hex = target.to_hex();
1753    let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1754    let may_strip = authority.roles.can_act_on_member(
1755        &my_pk.to_hex(),
1756        Some(&owner_hex),
1757        &target_hex,
1758        crate::community::roles::Permissions::MANAGE_ROLES,
1759    );
1760    if holds_roles && may_strip {
1761        grant_roles(transport, community, target, Vec::new())
1762            .await
1763            .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1764        if !session.is_valid() {
1765            return Err("account changed during kick".to_string());
1766        }
1767    }
1768    let at_ms = now_ms();
1769    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1770    // A Kick is an authority action, so it cites its Grant like any other
1771    // (CORD-02 §5 / CORD-04 §5).
1772    let citation = required_authority_citation(community, &my_pk)?;
1773    let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1774    let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1775        .map_err(|e| e.to_string())?;
1776    if !session.is_valid() {
1777        return Err("account changed before send".to_string());
1778    }
1779    transport.publish(&wrap, &community.relays).await?;
1780    Ok(())
1781}
1782
1783/// A community's folded, delegation-authorized authority — the on-demand read
1784/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1785/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1786/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1787/// entity (floored locally but no head folded) before replacing it blind.
1788pub struct AuthorityView {
1789    pub roles: crate::community::roles::CommunityRoles,
1790    pub banned: std::collections::BTreeSet<String>,
1791    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1792    pub gapped: bool,
1793    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1794    pub floored: std::collections::BTreeSet<String>,
1795    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1796    pub head_entities: std::collections::BTreeSet<String>,
1797    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
1798    pub banned_at: std::collections::BTreeMap<String, u64>,
1799}
1800
1801/// Fetch + fold the community's current authority (CORD-04), paging older like
1802/// `follow_control` while the fold is gapped so a busy control plane can't push
1803/// the roster off the newest window. A fetch failure degrades fail-safe:
1804/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1805/// an outage, and a ban never lifts on withheld data.
1806pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1807    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1808    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1809        .unwrap_or_default()
1810        .into_iter()
1811        .filter(|(_, f)| f.0 == community.root_epoch.0)
1812        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1813        .collect();
1814    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1815
1816    let mut editions: Vec<ParsedEdition> = Vec::new();
1817    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1818    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1819    let mut oldest: Option<u64> = None;
1820    let mut until: Option<u64> = None;
1821    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1822    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1823    // error returns the stored bans (fail-safe), never an empty banlist that would
1824    // silently un-ban on withheld data.
1825    let mut a = fold_authority(community, &[], &floors);
1826    for _ in 0..FOLLOW_MAX_PAGES {
1827        // Quorum, DECLARED (the until→Full transport floor is gone): these
1828        // control reads tolerate a partial union — their fold semantics are
1829        // fail-safe on gaps (seeded banlists, withheld roster cache).
1830        let query = Query {
1831            kinds: vec![stream::KIND_WRAP],
1832            authors: vec![control.pk_hex()],
1833            until,
1834            limit: Some(FOLLOW_PAGE),
1835            evidence: crate::community::transport::Evidence::Quorum,
1836            ..Default::default()
1837        };
1838        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1839        let mut fresh = 0usize;
1840        for w in &wraps {
1841            if !seen_wraps.insert(w.id) {
1842                continue;
1843            }
1844            fresh += 1;
1845            let at = w.created_at.as_secs();
1846            if oldest.is_none_or(|o| at < o) {
1847                oldest = Some(at);
1848            }
1849            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1850                if seen.insert(ed.inner_id) {
1851                    editions.push(ed);
1852                }
1853            }
1854        }
1855        a = fold_authority(community, &editions, &floors);
1856        if !a.gapped || fresh == 0 {
1857            break;
1858        }
1859        until = oldest;
1860    }
1861    AuthorityView {
1862        roles: a.roles,
1863        banned: a.banned,
1864        gapped: a.gapped,
1865        floored: floors.keys().cloned().collect(),
1866        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1867        banned_at: a.banned_at,
1868    }
1869}
1870
1871/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
1872/// falls below `since_secs` (everything older is already held) or the plane is
1873/// exhausted. Returns the parsed events at/after the window plus the newest wrap
1874/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
1875///
1876/// PAGE bound rationale: a single 500-window silently drops a member whose Join
1877/// aged out (organic growth, or an insider flooding throwaway Joins), and
1878/// `refound_community` consumes the fold as its rekey recipient set — a dropped
1879/// member is SEVERED. Beyond this depth a community needs sharding (documented);
1880/// the granted-member union in [`fold_members`] is the consensus-complete
1881/// backstop regardless of Guestbook depth.
1882async fn fetch_guestbook_events<T: Transport + ?Sized>(
1883    transport: &T,
1884    community: &CommunityV2,
1885    since_secs: u64,
1886) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1887    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1888    const GB_PAGE: usize = 500;
1889    const GB_MAX_PAGES: usize = 12;
1890    let mut events = Vec::new();
1891    let mut newest: u64 = since_secs;
1892    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1893    let mut until: Option<u64> = None;
1894    let mut oldest: Option<u64> = None;
1895    for _ in 0..GB_MAX_PAGES {
1896        // Full: this set becomes the refound's recipient list — a member's
1897        // Join visible only on a minority relay must not be severed.
1898        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() };
1899        let wraps = transport.fetch(&query, &community.relays).await?;
1900        let mut fresh = 0usize;
1901        for wrap in &wraps {
1902            if !seen.insert(wrap.id) {
1903                continue;
1904            }
1905            fresh += 1;
1906            let at = wrap.created_at.as_secs();
1907            if oldest.is_none_or(|o| at < o) {
1908                oldest = Some(at);
1909            }
1910            if at > newest {
1911                newest = at;
1912            }
1913            // Older than the cursor window — already held; skip the decrypt.
1914            if at < since_secs {
1915                continue;
1916            }
1917            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1918                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1919                    events.push(ev);
1920                }
1921            }
1922        }
1923        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1924            break;
1925        }
1926        match oldest {
1927            Some(o) if o > 0 => until = Some(o),
1928            _ => break,
1929        }
1930    }
1931    Ok((events, newest))
1932}
1933
1934/// The shared membership fold: coalesce Guestbook events under the community's
1935/// authority (owner-supreme kicks, refounder snapshots), union observed authors
1936/// plus every roster grantee, subtract the banlist, and pin the proven owner.
1937/// One implementation, so the live and stored reads can't drift.
1938fn fold_members(
1939    community: &CommunityV2,
1940    events: &[guestbook::GuestbookEvent],
1941    mut observed: std::collections::BTreeMap<PublicKey, u64>,
1942    roles: &crate::community::roles::CommunityRoles,
1943    banlist: &std::collections::BTreeSet<PublicKey>,
1944    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1945) -> Result<Vec<PublicKey>, String> {
1946    let owner = community.owner()?;
1947    let owner_hex = owner.to_hex();
1948    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1949
1950    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1951    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1952    // even if their Join aged out of the Guestbook entirely and they never posted.
1953    // This is what keeps a Refounding from severing a lurking admin. `observed`
1954    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1955    // still removes a banned grantee whose grant wasn't yet stripped.
1956    for g in &roles.grants {
1957        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1958            observed.entry(pk).or_insert(0);
1959        }
1960    }
1961
1962    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1963    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
1964    // honored here, so a silent survivor stays in the memberlist across an owner refound
1965    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
1966    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
1967    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
1968    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
1969    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
1970    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1971    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
1972    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
1973    // kick equal).
1974    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1975        let actor_hex = actor.to_hex();
1976        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1977            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1978    };
1979    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1980    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1981    // The owner is a member by definition, independent of any fetched Join.
1982    if !banlist.contains(&owner) {
1983        members.insert(owner);
1984    }
1985    Ok(members.into_iter().collect())
1986}
1987
1988/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
1989///
1990/// This is the only sound basis for acting on a kick against ourselves. The
1991/// memberlist is the wrong question: it also folds the banlist, the ban marks and
1992/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
1993/// where the store starts empty while the control fold has already re-derived their
1994/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
1995/// only "what is the latest authorized entry for this npub", so a fresh Join
1996/// supersedes an old Kick and an empty store yields no verdict at all.
1997pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
1998    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1999    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2000        return false;
2001    };
2002    let Ok(owner) = community.owner() else { return false };
2003    let owner_hex = owner.to_hex();
2004    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2005    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2006    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2007        let actor_hex = actor.to_hex();
2008        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2009            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2010    };
2011    matches!(
2012        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2013        Some(st) if st.verdict == guestbook::Verdict::Kicked
2014    )
2015}
2016
2017/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
2018/// from zero). The fetch straddles the network, so the session re-checks before
2019/// the store writes. Returns the events that were NEW to the store — the caller
2020/// surfaces them (presence lines) and refreshes on non-empty.
2021pub async fn sync_guestbook<T: Transport + ?Sized>(
2022    transport: &T,
2023    community: &CommunityV2,
2024    session: &SessionGuard,
2025) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2026    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2027    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2028    // Overlap one second so a same-second boundary event can't slip the cursor;
2029    // the rumor-id merge below dedups the re-fetched edge.
2030    let since = cursor.saturating_sub(1);
2031    let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2032    if !session.is_valid() {
2033        return Err("account changed during guestbook sync".to_string());
2034    }
2035    let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2036    let mut added = Vec::new();
2037    for ev in fresh {
2038        if !known.contains(&ev.rumor_id) {
2039            events.push(ev.clone());
2040            added.push(ev);
2041        }
2042    }
2043    if !added.is_empty() || newest > cursor {
2044        crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2045    }
2046    Ok(added)
2047}
2048
2049/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
2050/// Returns whether it was new.
2051pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2052    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2053    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2054    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2055        return Ok(false);
2056    }
2057    events.push(ev);
2058    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2059    Ok(true)
2060}
2061
2062/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
2063/// observed authors (the synced events DB), plus roster grantees, minus the
2064/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
2065/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
2066/// remains the authoritative walk — a refounding's rekey recipient set must
2067/// never trust a possibly-stale store.
2068pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2069    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2070    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2071    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2072    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2073        if let Ok(pk) = PublicKey::parse(&npub) {
2074            observed.insert(pk, last_active_secs.saturating_mul(1000));
2075        }
2076    }
2077    let roles = crate::db::community::get_community_roles(&cid_hex)?;
2078    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2079        .unwrap_or_default()
2080        .iter()
2081        .filter_map(|h| PublicKey::from_hex(h).ok())
2082        .collect();
2083    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
2084    // since this path never folds editions.
2085    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2086        .unwrap_or_default()
2087        .into_iter()
2088        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2089        .collect();
2090    fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2091}
2092
2093/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2094/// ALWAYS a member (derived from the self-certifying community_id — no network,
2095/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2096/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2097/// member whose Join was lost still counts.
2098pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2099    let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2100    // Observed authors: fold each held channel's recent authorship (real author +
2101    // newest ms), so a member who posted but whose Join was lost is still counted.
2102    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2103    for ch in &community.channels {
2104        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2105            for f in &page {
2106                let e = observed.entry(f.event.opened().author).or_insert(0);
2107                *e = (*e).max(f.event.opened().at_ms);
2108            }
2109        }
2110    }
2111
2112    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2113    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2114    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2115    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2116    let authority = fetch_authority(transport, community).await;
2117    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2118    let banlist: std::collections::BTreeSet<PublicKey> =
2119        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2120    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2121    // editions still in its window, and a ban that aged out is exactly the one whose
2122    // pre-ban Join would phantom.
2123    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2124        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2125    )
2126    .unwrap_or_default()
2127    .into_iter()
2128    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2129    .collect();
2130    for (h, at) in &authority.banned_at {
2131        if let Ok(pk) = PublicKey::from_hex(h) {
2132            let slot = banned_at.entry(pk).or_insert(0);
2133            *slot = (*slot).max(*at);
2134        }
2135    }
2136    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2137}
2138
2139// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2140
2141/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2142/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2143/// past or present member resolves the same grave and a Refounding can never strand
2144/// it). The tombstone's presence IS the state; only the owner's seal counts.
2145/// Irreversible — on success the local hold is sealed read-only.
2146pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2147    let session = SessionGuard::capture();
2148    let signer = crate::signer::active_signer()?;
2149    let my_pk = me_pk()?;
2150    if community.owner()? != my_pk {
2151        return Err("only the owner can dissolve a community".to_string());
2152    }
2153    let at = now_ms() / 1000;
2154    let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2155    let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2156    if !session.is_valid() {
2157        return Err("account changed during dissolve".to_string());
2158    }
2159    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2160    transport.publish_durable(&wrap, &community.relays).await?;
2161    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2162    Ok(())
2163}
2164
2165/// Whether a valid owner-signed dissolution tombstone exists for this community on
2166/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
2167/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
2168/// owner-verified tombstone found is authoritative.
2169pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2170    let group = super::derive::dissolved_group_key(community.id());
2171    let query = Query {
2172        kinds: vec![stream::KIND_WRAP],
2173        authors: vec![group.pk_hex()],
2174        limit: Some(20),
2175        ..Default::default()
2176    };
2177    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2178        return false;
2179    };
2180    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2181}
2182
2183// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
2184
2185/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
2186/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
2187/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
2188/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
2189/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
2190/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
2191///
2192/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
2193/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
2194/// transient miss never strands a published rekey with a half-anchored plane.
2195pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2196    let session = SessionGuard::capture();
2197    let cid = community.id();
2198    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2199    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
2200    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2201        return Err("this community has been dissolved; it cannot be re-founded".to_string());
2202    }
2203    let signer = crate::signer::active_signer()?;
2204    let my_pk = me_pk()?;
2205    // Serialize with the follow worker for the whole rotation: the commit tail
2206    // whole-row-saves, and an unserialized concurrent follow could otherwise be
2207    // rolled back (or adopt a half-published sibling of this very rotation).
2208    let lock = super::realtime::follow_lock(cid);
2209    let _guard = lock.lock().await;
2210    // Reload the FRESHEST base state: a stale caller struct would address the rotation
2211    // under a superseded root (a base fork with no heal). The community_id is
2212    // self-certifying + stable, so re-loading by it is safe.
2213    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2214    let community = &fresh;
2215    let owner = community.owner()?;
2216
2217    // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
2218    // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
2219    // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
2220    // and the banlist authority fold: any admin holding BAN may re-found, checked
2221    // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
2222    // only the owner able to re-found.
2223    {
2224        let owner_hex = owner.to_hex();
2225        let me_hex = my_pk.to_hex();
2226        // Persisted (last-folded) roster — the receive side is authoritative, so
2227        // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
2228        // collapses to owner-only, which can only OVER-restrict a fresh admin whose
2229        // grant hasn't folded into their own DB (the caller's ban flow folds control
2230        // first). It can never grant authority no one has.
2231        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2232        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2233        let authorized = my_pk == owner
2234            || (!banned.contains(&me_hex)
2235                && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2236                && removed.iter().all(|t| {
2237                    roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2238                }));
2239        if !authorized {
2240            return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2241        }
2242    }
2243
2244    // Fold the current roster: the opened editions are reused for the compaction (their
2245    // seals re-wrap under the new epoch), and the roster gates which admin-authored
2246    // heads carry forward.
2247    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2248        .into_iter()
2249        .filter(|(_, f)| f.0 == community.root_epoch.0)
2250        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2251        .collect();
2252    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2253    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
2254    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
2255    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
2256    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
2257    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
2258    // at the new epoch a fresh joiner bootstraps.
2259    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2260    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2261    let mut oldest: Option<u64> = None;
2262    let mut until: Option<u64> = None;
2263    // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
2264    // first-ever Banlist published while we were away) is invisible to a
2265    // coverage test, so stopping there could compact it away.
2266    let mut truncated = false;
2267    for page in 0..COMPACT_MAX_PAGES {
2268        // Full: compaction re-wraps the head set it can SEE — a control
2269        // edition (a ban head) reachable only on a minority relay must not be
2270        // compacted away by a partial union.
2271        let query = Query {
2272            kinds: vec![stream::KIND_WRAP],
2273            authors: vec![current_control.pk_hex()],
2274            until,
2275            limit: Some(FOLLOW_PAGE),
2276            evidence: crate::community::transport::Evidence::Full,
2277            ..Default::default()
2278        };
2279        let wraps = transport.fetch(&query, &community.relays).await?;
2280        let mut fresh = 0usize;
2281        for w in &wraps {
2282            if !seen_wraps.insert(w.id) {
2283                continue;
2284            }
2285            fresh += 1;
2286            let at = w.created_at.as_secs();
2287            if oldest.is_none_or(|o| at < o) {
2288                oldest = Some(at);
2289            }
2290            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2291                opened.push(parsed);
2292            }
2293        }
2294        if fresh == 0 {
2295            // `until` is inclusive: a FULL page with nothing new is a same-second
2296            // wall no cursor steps past, so older editions stay unreachable. A
2297            // short page is simply the end of the plane.
2298            truncated = wraps.len() >= FOLLOW_PAGE;
2299            break;
2300        }
2301        until = oldest;
2302        if page + 1 == COMPACT_MAX_PAGES {
2303            truncated = true;
2304        }
2305    }
2306    if truncated {
2307        return Err(
2308            "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(),
2309        );
2310    }
2311
2312    let prev_epoch = community.root_epoch;
2313    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2314    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2315    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
2316    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
2317    // it can't double-mint two roots a receiver's correlation dedup would collapse into
2318    // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
2319    // this DB write — re-check so a mid-fetch swap can't archive into another account.
2320    if !session.is_valid() {
2321        return Err("account changed during re-founding compaction".to_string());
2322    }
2323    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2324    let new_control = control_group_key(&new_root, cid, new_epoch);
2325    let at = now_ms();
2326    let at_secs = at / 1000;
2327
2328    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
2329    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
2330    // including entities the metadata/roster folds don't touch (the invite Registry
2331    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
2332    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
2333    use std::collections::BTreeMap;
2334    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2335    for (i, (e, _)) in opened.iter().enumerate() {
2336        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2337    }
2338    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2339    for (floor_key, floor) in &floors {
2340        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
2341        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
2342        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
2343        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
2344        // edition chaining onto the floor, which the tip would carry and honest folders
2345        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
2346        // the refounding. Abort if the committed head isn't served (fail-closed).
2347        let head_idx = by_eid
2348            .get(floor_key)
2349            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2350        let Some(head_idx) = head_idx else {
2351            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2352        };
2353        let (head_ed, head_os) = &opened[head_idx];
2354        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2355        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2356        carried.push((h, rewrapped));
2357    }
2358    if !session.is_valid() {
2359        return Err("account changed during re-founding acquire".to_string());
2360    }
2361
2362    // Recipients: the current members minus `removed`, plus me (multi-device).
2363    let members = memberlist(transport, community).await?;
2364    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2365    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2366    if !recipients.iter().any(|p| *p == my_pk) {
2367        recipients.push(my_pk);
2368    }
2369
2370    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
2371    let mut base_blobs = Vec::new();
2372    for r in &recipients {
2373        base_blobs.push(
2374            super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2375                .await
2376                .map_err(|e| e.to_string())?,
2377        );
2378    }
2379    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2380    let base_chunks =
2381        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())
2382            .await
2383            .map_err(|e| e.to_string())?;
2384
2385    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
2386    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
2387    //
2388    // Each private channel goes only to ITS entitled set, never the base recipient
2389    // list: a Refounding that re-broadcast every private key to every member would
2390    // undo the access lists on every rotation (CORD-03).
2391    // Entitlement must come from a CURRENT roster, not the last-folded cache: the
2392    // base recipients above are a fresh network fold, and mixing the two strands
2393    // anyone granted since this client last folded — they keep a dead key and the
2394    // new epoch's rekey plane carries no blob for them. Fetched, then merged over
2395    // the cache so a role we published ourselves survives too.
2396    let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2397    {
2398        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2399        for r in cached.roles {
2400            if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2401                roster_for_channels.roles.push(r);
2402            }
2403        }
2404        for g in cached.grants {
2405            if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2406                roster_for_channels.grants.push(g);
2407            }
2408        }
2409    }
2410    if !session.is_valid() {
2411        return Err("account changed during re-founding entitlement fetch".to_string());
2412    }
2413    let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2414    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2415    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2416    for ch in &community.channels {
2417        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2418        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2419        let entitled: Vec<PublicKey> = recipients
2420            .iter()
2421            .copied()
2422            .filter(|r| {
2423                *r == my_pk
2424                    || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2425            })
2426            .collect();
2427        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2428        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
2429        // retry-idempotency as the base root. The base-rekey signing above is a bunker
2430        // round-trip; re-check before this per-channel DB write straddles it.
2431        if !session.is_valid() {
2432            return Err("account changed during re-founding channel prepare".to_string());
2433        }
2434        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)?;
2435        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2436        let mut ch_blobs = Vec::new();
2437        for r in &entitled {
2438            ch_blobs.push(
2439                super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2440                    .await
2441                    .map_err(|e| e.to_string())?,
2442            );
2443        }
2444        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2445        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())
2446            .await
2447            .map_err(|e| e.to_string())?;
2448        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2449        channel_chunk_sets.push(ch_chunks);
2450    }
2451    if !session.is_valid() {
2452        return Err("account changed during re-founding prepare".to_string());
2453    }
2454
2455    // COMMIT (durable publishes only — all fetching is done). Base rekey first
2456    // (delivers the new root), then channel rekeys, then the compacted control.
2457    for c in &base_chunks {
2458        transport.publish_durable(c, &community.relays).await?;
2459    }
2460    for set in &channel_chunk_sets {
2461        for c in set {
2462            transport.publish_durable(c, &community.relays).await?;
2463        }
2464    }
2465    for (_, wrap) in &carried {
2466        transport.publish_durable(wrap, &community.relays).await?;
2467    }
2468    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
2469    // it; an omitted member heals by publishing their own Join).
2470    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2471    let snap_id = crate::community::random_32();
2472    for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2473        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2474            let _ = transport.publish(&wrap, &community.relays).await;
2475        }
2476    }
2477
2478    // COMMIT locally, only now that the new root + compacted plane are on relays.
2479    if !session.is_valid() {
2480        return Err("account changed during re-founding commit".to_string());
2481    }
2482    if crate::db::community::community_protocol(cid)?.is_none() {
2483        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
2484    }
2485    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
2486    // never leave the base root advanced while the channel keys lag (which would
2487    // re-derive the channel rekey address under the wrong root and orphan them).
2488    let mut updated = community.clone();
2489    updated.community_root = new_root;
2490    updated.root_epoch = new_epoch;
2491    for (id, key, ep) in &channel_updates {
2492        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2493            c.key = Some(*key);
2494            c.epoch = *ep;
2495        }
2496    }
2497    crate::db::community::save_community_v2(&updated)?;
2498    // Archive the new epoch key + confirm the monotonic base head (the root was already
2499    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
2500    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
2501    // compacted control on the next follow, so they self-heal.
2502    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2503    for (h, _) in &carried {
2504        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2505    }
2506    // Re-subscribe NOW: the rotation changed every plane author, and the live sub
2507    // still carries the OLD epoch's set. Members adopt via the follow worker
2508    // (which refreshes); the REFOUNDER has no such path — without this, the very
2509    // client that performed the ban goes deaf to the new epoch (a rejoin lands on
2510    // the relays and never arrives live).
2511    if let Some(client) = crate::state::nostr_client() {
2512        super::realtime::refresh_subscription(&client).await;
2513    }
2514    // Refresh any live public links so their bundles carry the NEW root behind the
2515    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
2516    // so retry a transient failure — a stranded link lands a new joiner on the dead
2517    // pre-refound epoch, and there's no other trigger to heal it before the next
2518    // refounding. A persistent failure is logged (refound already succeeded).
2519    for attempt in 0..3u8 {
2520        match refresh_public_links(transport, &updated).await {
2521            Ok(()) => break,
2522            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
2523            Err(e) if attempt == 2 => {
2524                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2525            }
2526            Err(_) => continue,
2527        }
2528    }
2529    Ok(updated)
2530}
2531
2532/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
2533/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
2534/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
2535/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
2536/// the two sets are DECOUPLED:
2537///
2538/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
2539///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
2540///   set at {owner} also dodges the 120-blob rotation cap for large communities.
2541/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
2542///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
2543///
2544/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
2545/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
2546/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
2547pub async fn refound_at_birth<T: Transport + ?Sized>(
2548    transport: &T,
2549    community: &CommunityV2,
2550    snapshot_members: &[PublicKey],
2551) -> Result<CommunityV2, String> {
2552    let session = SessionGuard::capture();
2553    let cid = community.id();
2554    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2555    // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
2556    // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
2557    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2558        return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2559    }
2560    let signer = crate::signer::active_signer()?;
2561    let my_pk = me_pk()?;
2562    if my_pk != community.owner()? {
2563        return Err("only the owner can birth-refound the migration twin".to_string());
2564    }
2565    let lock = super::realtime::follow_lock(cid);
2566    let _guard = lock.lock().await;
2567    let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2568    // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
2569    // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
2570    // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
2571    // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
2572    // unexpected (nothing else rotates a mid-migration twin).
2573    if community.root_epoch.0 == 1 {
2574        return Ok(community);
2575    }
2576    if community.root_epoch.0 != 0 {
2577        return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2578    }
2579    let community = &community;
2580
2581    // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
2582    // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
2583    // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
2584    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2585        .into_iter()
2586        .filter(|(_, f)| f.0 == community.root_epoch.0)
2587        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2588        .collect();
2589    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2590    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2591    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2592    let mut oldest: Option<u64> = None;
2593    let mut until: Option<u64> = None;
2594    // Exhaustion, not coverage — see the sibling read in `refound_community`.
2595    let mut truncated = false;
2596    for page in 0..COMPACT_MAX_PAGES {
2597        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() };
2598        let wraps = transport.fetch(&query, &community.relays).await?;
2599        let mut fresh = 0usize;
2600        for w in &wraps {
2601            if !seen_wraps.insert(w.id) { continue; }
2602            fresh += 1;
2603            let at = w.created_at.as_secs();
2604            if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2605            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2606                opened.push(parsed);
2607            }
2608        }
2609        if fresh == 0 {
2610            truncated = wraps.len() >= FOLLOW_PAGE;
2611            break;
2612        }
2613        until = oldest;
2614        if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2615    }
2616    if truncated {
2617        return Err(
2618            "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(),
2619        );
2620    }
2621
2622    let prev_epoch = community.root_epoch; // 0
2623    let new_epoch = Epoch(1);
2624    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2625    if !session.is_valid() {
2626        return Err("account changed during birth-refound compaction".to_string());
2627    }
2628    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2629    let new_control = control_group_key(&new_root, cid, new_epoch);
2630    let at = now_ms();
2631    let at_secs = at / 1000;
2632
2633    use std::collections::BTreeMap;
2634    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2635    for (i, (e, _)) in opened.iter().enumerate() {
2636        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2637    }
2638    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2639    for (floor_key, floor) in &floors {
2640        let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2641        let Some(head_idx) = head_idx else {
2642            return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2643        };
2644        let (head_ed, head_os) = &opened[head_idx];
2645        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2646        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2647        carried.push((h, rewrapped));
2648    }
2649    if !session.is_valid() {
2650        return Err("account changed during birth-refound acquire".to_string());
2651    }
2652
2653    // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the carrier's `m`).
2654    let base_blobs = vec![
2655        super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2656            .await
2657            .map_err(|e| e.to_string())?,
2658    ];
2659    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2660    let base_chunks =
2661        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())
2662            .await
2663            .map_err(|e| e.to_string())?;
2664    if !session.is_valid() {
2665        return Err("account changed during birth-refound prepare".to_string());
2666    }
2667
2668    // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
2669    for c in &base_chunks {
2670        transport.publish_durable(c, &community.relays).await?;
2671    }
2672    for (_, wrap) in &carried {
2673        transport.publish_durable(wrap, &community.relays).await?;
2674    }
2675    // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
2676    // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
2677    // live members heal via their own Join if a chunk drops), a seeded-never-landed member
2678    // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
2679    // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
2680    // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
2681    // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
2682    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2683    let snap_id = crate::community::random_32();
2684    let snapshot_wraps: Vec<Event> = {
2685        let mut out = Vec::new();
2686        for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2687            let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2688                .await
2689                .map_err(|e| format!("seal birth snapshot: {e}"))?;
2690            out.push(wrap);
2691        }
2692        out
2693    };
2694    for wrap in &snapshot_wraps {
2695        transport.publish_durable(wrap, &community.relays).await?;
2696    }
2697    // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
2698    // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
2699    // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
2700    // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
2701    // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
2702    // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
2703    // banlist from the expected set. The real caller never seeds a banned member, but the
2704    // arbitrary-`snapshot_members` API must not be able to wedge on one.
2705    let verify_view = {
2706        let mut v = community.clone();
2707        v.community_root = new_root;
2708        v.root_epoch = new_epoch;
2709        v
2710    };
2711    let expected: Vec<PublicKey> = {
2712        let banlist = fetch_authority(transport, &verify_view).await.banned;
2713        snapshot_members.iter().copied()
2714            .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2715            .collect()
2716    };
2717    if !expected.is_empty() {
2718        let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2719        let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2720        if missing > 0 {
2721            return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2722        }
2723    }
2724
2725    // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
2726    if !session.is_valid() {
2727        return Err("account changed during birth-refound commit".to_string());
2728    }
2729    if crate::db::community::community_protocol(cid)?.is_none() {
2730        return Ok(community.clone());
2731    }
2732    let mut updated = community.clone();
2733    updated.community_root = new_root;
2734    updated.root_epoch = new_epoch;
2735    crate::db::community::save_community_v2(&updated)?;
2736    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2737    for (h, _) in &carried {
2738        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2739    }
2740    Ok(updated)
2741}
2742
2743/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
2744/// already archived from a prior (aborted) attempt — so a retried Refounding re-
2745/// delivers the SAME key at the same epoch/address instead of double-minting two roots
2746/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
2747/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
2748/// sentinel for a base rotation, else the channel_id hex.
2749fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2750    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2751        return Ok(existing);
2752    }
2753    let fresh = crate::community::random_32();
2754    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2755    Ok(fresh)
2756}
2757
2758// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
2759
2760/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
2761/// icon (a rehydrating device folds it from the Control Plane), never the link
2762/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
2763fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2764    let hex = crate::simd::hex::bytes_to_hex_32;
2765    let channels = community
2766        .channels
2767        .iter()
2768        .filter(|c| c.private)
2769        // Keyed channels ONLY. A keyless entry is readable by this build but is
2770        // rejected outright by shipped ones (their `key` is a required String),
2771        // so emitting one would strand every older client on a stale list.
2772        .filter_map(|c| {
2773            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2774        })
2775        .collect();
2776    super::list::JoinMaterial {
2777        community_id: hex(&community.identity.community_id.0),
2778        owner: hex(&community.identity.owner_xonly),
2779        owner_salt: hex(&community.identity.owner_salt),
2780        community_root: hex(&community.community_root),
2781        root_epoch: community.root_epoch.0,
2782        channels,
2783        relays: community.relays.clone(),
2784        name: community.name.clone(),
2785        extra: Default::default(),
2786    }
2787}
2788
2789/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
2790/// (the material IS the membership subset of a bundle). The owner root is still
2791/// verified over the network before the community is trusted (accept_bundle).
2792fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2793    // A keyless listing records that the channel EXISTS, not a grant — there is
2794    // nothing to seat, and it keys up when access is granted.
2795    let channels = jm
2796        .channels
2797        .iter()
2798        .filter_map(|c| {
2799            c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2800        })
2801        .collect();
2802    CommunityInvite {
2803        community_id: jm.community_id.clone(),
2804        owner: jm.owner.clone(),
2805        owner_salt: jm.owner_salt.clone(),
2806        community_root: jm.community_root.clone(),
2807        root_epoch: jm.root_epoch,
2808        channels,
2809        relays: jm.relays.clone(),
2810        name: jm.name.clone(),
2811        icon: None,
2812        expires_at: None,
2813        creator_npub: None,
2814        label: None,
2815        extra: Default::default(),
2816    }
2817}
2818
2819/// The union of every held v2 community's relays — where this account's 13302 list
2820/// lives (a fresh device that opens any held community reaches the same set).
2821fn held_v2_relays() -> Vec<String> {
2822    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2823    if let Ok(ids) = crate::db::community::list_community_ids() {
2824        for id in ids {
2825            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2826                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2827                    set.extend(c.relays);
2828                }
2829            }
2830        }
2831    }
2832    set.into_iter().collect()
2833}
2834
2835/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
2836/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
2837/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
2838/// must NOT drive a replaceable-event write from a failed read — it would clobber
2839/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
2840async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2841    let signer = crate::signer::active_signer()?;
2842    let my_pk = me_pk()?;
2843    let query = Query {
2844        kinds: vec![super::kind::COMMUNITY_LIST],
2845        authors: vec![my_pk.to_hex()],
2846        limit: Some(4),
2847        ..Default::default()
2848    };
2849    let events = transport.fetch(&query, relays).await?;
2850    let seen = events.len();
2851    // Which copy won matters: relays disagree (one may hold a stale replaceable),
2852    // and a list near the NIP-44 ceiling stops accepting joins — both are invisible
2853    // without saying so.
2854    let mut undecryptable = 0usize;
2855    let mut unreadable: Option<(u64, String, usize, String)> = None;
2856    let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2857    for e in events {
2858        let at = e.created_at.as_secs();
2859        let id_hex = e.id.to_hex();
2860        let content_len = e.content.len();
2861        match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2862            Ok(l) => {
2863                if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2864                    best = Some((at, id_hex, l));
2865                }
2866            }
2867            Err(err) => {
2868                undecryptable += 1;
2869                if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2870                    unreadable = Some((at, id_hex, content_len, err.to_string()));
2871                }
2872            }
2873        }
2874    }
2875    // Only the case that costs data is worth a warning: a copy we could not read
2876    // that was NEWER than the one we settled for. That silently pins the account
2877    // to stale membership, and the parse error is the only clue to why.
2878    if let Some((at, id, len, err)) = &unreadable {
2879        if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2880            crate::log_net_fail!(
2881                "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2882                &id[..8]
2883            );
2884        }
2885    }
2886    if let Some((at, id, l)) = &best {
2887        let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2888        crate::log_debug!(
2889            "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2890            &id[..8],
2891            super::stream::NIP44_MAX_PLAINTEXT
2892        );
2893    }
2894    Ok(best.map(|(_, _, l)| l))
2895}
2896
2897/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
2898/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
2899/// `just_joined` is the community THIS call is recording a create/join for — the
2900/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
2901/// (a deliberate re-join resurrects). Every OTHER held community that the remote
2902/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
2903/// because we joined something else — the W1 resurrection hole). Idempotent;
2904/// best-effort — a list-publish failure never fails the membership change itself.
2905/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
2906/// skipped without failing the caller (a failed remote fetch — see below). Callers that
2907/// need the membership to actually land use [`republish_community_list_durable`].
2908pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2909    let session = SessionGuard::capture();
2910    let signer = crate::signer::active_signer()?;
2911    let my_pk = me_pk()?;
2912    let relays = held_v2_relays();
2913    if relays.is_empty() {
2914        return Ok(false); // nothing held → nothing to sync
2915    }
2916    // A FAILED remote fetch must not drive this replaceable-event write: publishing
2917    // a list built without the remote seeds would drop older-epoch backfill anchors
2918    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
2919    let remote = match fetch_community_list(transport, &relays).await {
2920        Ok(r) => r.unwrap_or_default(),
2921        Err(e) => {
2922            // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
2923            // remote seeds drops backfill anchors), but the membership this call was meant
2924            // to record is now simply unrecorded. A join that lands here leaves a community
2925            // held locally with no list entry — and if it also carries an older tombstone,
2926            // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
2927            crate::log_warn!(
2928                "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2929                e,
2930                just_joined
2931                    .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2932                    .unwrap_or_default()
2933            );
2934            return Ok(false);
2935        }
2936    };
2937    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2938    let now = now_ms();
2939    let mut local = super::list::CommunityList::default();
2940    for id in crate::db::community::list_community_ids()? {
2941        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2942            continue;
2943        }
2944        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2945        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2946        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2947        // A held community the remote has tombstoned (a sibling device left it) that
2948        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
2949        // different community would silently undo the leave everywhere.
2950        //
2951        // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
2952        // reached the list (this publish is best-effort — a failed remote fetch
2953        // silently skips it) leaves a tombstone with no entry, and nothing can ever
2954        // out-rank it again: every boot the list sync reads "removed", tears the
2955        // community down, the rejoin re-adds it, and it loops forever. Our own hold
2956        // is first-hand evidence of membership, so let it settle the tie by the same
2957        // add-vs-remove rule the list already uses everywhere else.
2958        let tombstoned_at = remote
2959            .tombstones
2960            .iter()
2961            .find(|t| t.community_id == cid_hex)
2962            .map(|t| t.removed_at)
2963            .unwrap_or(0);
2964        let held_since = c.created_at_ms;
2965        if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2966            crate::log_warn!(
2967                "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2968                &cid_hex[..8], tombstoned_at, held_since
2969            );
2970            continue;
2971        }
2972        // Keep an already-live entry's add time (no churn); the joined community (or a
2973        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
2974        // that outlived a tombstone re-asserts itself at its own join time, which is
2975        // already newer than the removal.
2976        let added_at = if remote.is_live(&cid_hex) && !is_join {
2977            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2978        } else if !is_join && tombstoned_at > 0 {
2979            held_since
2980        } else {
2981            now
2982        };
2983        let jm = join_material(&c);
2984        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
2985    }
2986    let merged = remote.merge(&local);
2987    merged.assert_fits().map_err(|e| e.to_string())?;
2988    let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
2989    if !session.is_valid() {
2990        return Err("account changed during community-list publish".to_string());
2991    }
2992    if let Err(e) = transport.publish(&event, &relays).await {
2993        crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
2994        return Err(e);
2995    }
2996    Ok(true)
2997}
2998
2999/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
3000/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
3001/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
3002/// instead of spinning.
3003const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3004
3005/// Record a membership across devices DURABLY: retry in the background until the list
3006/// actually lands.
3007///
3008/// [`republish_community_list`] must never fail a join, and it deliberately publishes
3009/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
3010/// other devices' entries). One shot at that means a relay blip during a join leaves the
3011/// membership unrecorded until the user happens to join something else — and if a stale
3012/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
3013///
3014/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
3015/// generic fn directly). The `SessionGuard` is captured BEFORE the spawn and re-checked
3016/// before every attempt, so an account swap mid-backoff can't publish A's list from B.
3017pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3018    if crate::state::nostr_client().is_none() {
3019        return;
3020    }
3021    let session = SessionGuard::capture();
3022    tokio::spawn(async move {
3023        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3024            if !session.is_valid() {
3025                return;
3026            }
3027            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3028            match republish_community_list(&transport, just_joined.as_ref()).await {
3029                Ok(true) => {
3030                    if attempt > 0 {
3031                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3032                    }
3033                    return;
3034                }
3035                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
3036                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3037            }
3038            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3039        }
3040        crate::log_warn!(
3041            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3042            LIST_REPUBLISH_BACKOFF_SECS.len()
3043        );
3044    });
3045}
3046
3047/// Record a permanent leave tombstone for `community_id` in the 13302, published to
3048/// `relays` (the leaving community's own, since it's about to be deleted locally).
3049async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3050    let signer = crate::signer::active_signer()?;
3051    let my_pk = me_pk()?;
3052    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3053    // A failed fetch here would drop other communities' entries (only the
3054    // tombstone would survive); preserve them by bailing — the leave re-records
3055    // on the next attempt, and the local teardown already happened.
3056    let mut doc = match fetch_community_list(transport, relays).await {
3057        Ok(d) => d.unwrap_or_default(),
3058        Err(e) => return Err(e),
3059    };
3060    let now = now_ms();
3061    doc.tombstones.retain(|t| t.community_id != cid_hex);
3062    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3063    doc.assert_fits().map_err(|e| e.to_string())?;
3064    let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3065    transport.publish(&event, relays).await
3066}
3067
3068/// Sync memberships from the 13302 across devices: fetch this account's list from
3069/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
3070/// a fresh device), and JOIN every live entry not already held — reconstructing the
3071/// community from its join material and re-verifying the owner root. Returns the
3072/// newly-rehydrated communities (so the caller can subscribe + notify).
3073/// What one Community-List sync changed locally.
3074pub struct ListSyncOutcome {
3075    /// Communities newly adopted from the list (already persisted + chat-registered).
3076    pub joined: Vec<CommunityV2>,
3077    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
3078    ///
3079    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
3080    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
3081    /// and it can't look them up afterwards. Deleting the community while leaving its chat
3082    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
3083    pub removed: Vec<(String, Vec<String>)>,
3084}
3085
3086pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3087    let session = SessionGuard::capture();
3088    let mut relays = held_v2_relays();
3089    relays.extend(bootstrap_relays.iter().cloned());
3090    relays.sort();
3091    relays.dedup();
3092    if relays.is_empty() {
3093        return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3094    }
3095    // A cross-device sync that finds nothing is indistinguishable from one that
3096    // never ran, so every exit says why — this path is only ever debugged after
3097    // the fact, from a user's log.
3098    let list = match fetch_community_list(transport, &relays).await {
3099        Ok(Some(l)) => {
3100            crate::log_debug!(
3101                "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3102                l.entries.len(),
3103                l.tombstones.len(),
3104                relays.len()
3105            );
3106            l
3107        }
3108        Ok(None) => {
3109            // Transient by nature: boot runs many concurrent passes and a relay that
3110            // times out under that load returns nothing. Only persistent absence
3111            // matters, and that shows up as "adopted nothing" anyway.
3112            crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3113            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3114        }
3115        Err(e) => {
3116            crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3117            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3118        }
3119    };
3120    // Receive-side teardown (the counterpart to the republish tombstone guard):
3121    // a community this device still holds but the synced list shows TOMBSTONED (a
3122    // sibling device left it) and NOT live gets torn down here, so a leave on one
3123    // device propagates to the others. A re-join would have re-added it live
3124    // (beating the tombstone), so is_live short-circuits the honest case.
3125    let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3126    for t in &list.tombstones {
3127        if list.is_live(&t.community_id) {
3128            continue;
3129        }
3130        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3131        let id = crate::community::CommunityId(cid);
3132        let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3133            continue; // not held — nothing to tear down
3134        };
3135        // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
3136        // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
3137        // this would then delete the community on every sync. So let the LOCAL hold break the
3138        // tie too — a hold created after the removal IS the rejoin, whether or not its entry
3139        // ever reached the list. Same rule the v1 sweep uses.
3140        if held.created_at_ms > t.removed_at {
3141            crate::log_warn!(
3142                "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3143                &t.community_id[..8], t.removed_at, held.created_at_ms
3144            );
3145            continue;
3146        }
3147        if !session.is_valid() {
3148            return Err("account changed during community-list sync".to_string());
3149        }
3150        let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3151        let _ = crate::db::community::delete_community(&t.community_id);
3152        removed.push((t.community_id.clone(), channel_ids));
3153    }
3154    let mut joined = Vec::new();
3155    for entry in list.live_entries() {
3156        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3157        if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
3158            continue; // already held
3159        }
3160        if !session.is_valid() {
3161            return Err("account changed during community-list sync".to_string());
3162        }
3163        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
3164        // and seeds floors. NO Guestbook Join: this device is receiving keys the
3165        // account already holds elsewhere — the membership was announced when it
3166        // actually joined, and a key sync is not a membership event.
3167        let bundle = material_to_invite(&entry.current);
3168        match accept_bundle(transport, &session, &bundle, None, false).await {
3169            Ok(community) => joined.push(community),
3170            // A listed-but-unadoptable entry is the failure mode that reads as
3171            // "cross-device sync is broken": the community never appears and any
3172            // parked invite for it is never retired.
3173            Err(e) => crate::log_net_fail!(
3174                "[CommunityList] {} is listed but adoption failed: {e}",
3175                &entry.community_id[..entry.community_id.len().min(8)]
3176            ),
3177        }
3178    }
3179    Ok(ListSyncOutcome { joined, removed })
3180}
3181
3182// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
3183
3184/// Publish one control edition (a role, grant, banlist, community-metadata, or
3185/// channel-metadata edit) at the next version for its entity, chaining `prev` from
3186/// our held head, and advance our local floor. Authority is enforced by every
3187/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
3188/// requires only a valid local signer; a well-behaved client checks its own rank
3189/// first, but a reader drops an unauthorized edition regardless.
3190/// This actor's authority citation for a control edition (CORD-04 §5): the head
3191/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
3192///
3193/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
3194/// at least this Grant, then resolves rank against its CURRENT roster, so a
3195/// demoted admin is never grandfathered by an old-but-once-valid citation.
3196///
3197/// `None` for the owner (supreme, rank comes from the community id) and `None`
3198/// when no Grant head is held — an actor who cannot cite has no rank to claim,
3199/// and the edition is dropped by a conforming reader either way.
3200/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
3201/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
3202/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
3203/// cited version with the cited hash at the tip — else fail closed, because
3204/// honoring an action whose authority we can't confirm is exactly how a demoted
3205/// moderator keeps moderating.
3206///
3207/// Completeness only: the permission + outrank is the separate roster check, so a
3208/// since-demoted actor is refused there (refuse-superseded). An action citing a
3209/// version we haven't synced parks and is re-judged on the next roster sync — the
3210/// sync path can't escalate to a blocking fetch.
3211pub(super) fn citation_is_synced(
3212    cid_hex: &str,
3213    owner_hex: &str,
3214    actor_hex: &str,
3215    citation: Option<&crate::community::edition::AuthorityCitation>,
3216) -> bool {
3217    if owner_hex == actor_hex {
3218        return true;
3219    }
3220    if citation.is_none() {
3221        return false;
3222    }
3223    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3224    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3225    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3226        &crate::community::CommunityId(cid_bytes),
3227        &actor_bytes,
3228    ));
3229    let head: Vec<crate::community::roster::EntityHead> =
3230        crate::db::community::get_edition_head(cid_hex, &grant_hex)
3231            .ok()
3232            .flatten()
3233            .map(|(version, self_hash)| crate::community::roster::EntityHead {
3234                entity_hex: grant_hex.clone(),
3235                version,
3236                self_hash,
3237                inner_id: [0u8; 32],
3238                citation: None,
3239            })
3240            .into_iter()
3241            .collect();
3242    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3243}
3244
3245/// [`my_authority_citation`], but refusing to emit an action every reader will
3246/// drop (CORD-04 §5: an uncited non-owner action is not honored).
3247///
3248/// The citation is built from PERSISTED heads, which only `follow_control` writes
3249/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
3250/// otherwise publish uncited and have the action silently vanish on every client,
3251/// with nothing shown locally. Failing here turns that into one retryable error.
3252fn required_authority_citation(
3253    community: &CommunityV2,
3254    actor: &PublicKey,
3255) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3256    if community.owner().ok().as_ref() == Some(actor) {
3257        return Ok(None); // supreme, cites nothing
3258    }
3259    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3260        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3261    })
3262}
3263
3264fn my_authority_citation(
3265    community: &CommunityV2,
3266    actor: &PublicKey,
3267) -> Option<crate::community::edition::AuthorityCitation> {
3268    if community.owner().ok().as_ref() == Some(actor) {
3269        return None;
3270    }
3271    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3272    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3273    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3274    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3275        .ok()
3276        .flatten()
3277        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3278}
3279
3280/// Refuse a root-derived write whose in-hand struct predates a rotation.
3281///
3282/// A Ban's refound buries the old root while the caller's `CommunityV2` still
3283/// points at it; publishing there lands on a plane nobody folds — the action
3284/// "succeeds" and silently never happened (an unban that doesn't unban, an
3285/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
3286/// the caller reload and retry against the living root.
3287fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3288    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3289    match crate::db::community::get_server_root_epoch(&cid_hex)? {
3290        Some(held) if held != community.root_epoch.0 => Err(format!(
3291            "the community re-founded mid-action (epoch {} -> {held}); retry",
3292            community.root_epoch.0
3293        )),
3294        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
3295    }
3296}
3297
3298async fn publish_control_edition<T: Transport + ?Sized>(
3299    transport: &T,
3300    community: &CommunityV2,
3301    session: &SessionGuard,
3302    vsk: &str,
3303    entity_id: &[u8; 32],
3304    content: &str,
3305) -> Result<(), String> {
3306    assert_current_root(community)?;
3307    let signer = crate::signer::active_signer()?;
3308    let my_pk = me_pk()?;
3309    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3310    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3311    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3312    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3313        Some((v, h)) => (v + 1, Some(h)),
3314        None => (1, None),
3315    };
3316    // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
3317    // under. Computed here rather than passed in — the citation is a property of
3318    // WHO IS ACTING, identical for every entity kind, so deciding it per call
3319    // site is nine chances to forget (and nine were, silently: every site passed
3320    // None). The owner cites nothing; their rank is the community id itself.
3321    let citation = required_authority_citation(community, &my_pk)?;
3322    let at = now_ms() / 1000;
3323    let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3324    let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3325    if !session.is_valid() {
3326        return Err("account changed before control publish".to_string());
3327    }
3328    transport.publish(&wrap, &community.relays).await?;
3329    // Advance our own floor so a follow-up edit chains from this head and refuse-
3330    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
3331    // Re-check the session AFTER the publish await: a swap mid-publish means the
3332    // pool now points at another account's DB — skipping is safe (the next own
3333    // edit rebuilds the same head from the relay's copy).
3334    if !session.is_valid() {
3335        return Ok(());
3336    }
3337    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3338        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3339    }
3340    Ok(())
3341}
3342
3343/// Merge our OWN just-published Role/Grant into the locally stored roster.
3344///
3345/// v2 persists the roster only inside `follow_control`, so a role or grant we
3346/// just published is invisible to every sync local read (entitlement, capability
3347/// gates, the next grant) until the next fold. This writes what we are already
3348/// authorized to have written; the next fold recomputes from the plane and
3349/// converges. Mirrors the fold's own write, so the stored `roles_at` is left
3350/// alone — a real edition always outranks this optimistic merge.
3351fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3352    let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3353    if let Some(r) = role {
3354        match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3355            Some(slot) => *slot = r.clone(),
3356            None => roster.roles.push(r.clone()),
3357        }
3358    }
3359    if let Some(g) = grant {
3360        match roster.grants.iter_mut().find(|x| x.member == g.member) {
3361            Some(slot) => *slot = g.clone(),
3362            None => roster.grants.push(g.clone()),
3363        }
3364    }
3365    let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3366    if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3367        crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3368    }
3369}
3370
3371/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
3372/// rename or permission change is a versioned edit of the same id. Gated on the
3373/// reader side by `MANAGE_ROLES` + outrank.
3374pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3375    let session = SessionGuard::capture();
3376    super::roles::validate_role(role)?;
3377    let content = super::roles::role_content_json(role)?;
3378    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3379    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3380}
3381
3382/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
3383/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
3384/// member.
3385pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3386    let session = SessionGuard::capture();
3387    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3388    let content = super::roles::grant_content_json(&grant)?;
3389    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3390    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3391}
3392
3393/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
3394/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
3395/// every device, so concurrent grants converge as editions of ONE entity instead
3396/// of forking two Admin roles.
3397pub async fn ensure_admin_role<T: Transport + ?Sized>(
3398    transport: &T,
3399    community: &CommunityV2,
3400    view: &AuthorityView,
3401    create_if_missing: bool,
3402) -> Result<Option<String>, String> {
3403    use crate::community::roles::{Permissions, Role, RoleScope};
3404    if let Some(r) = view
3405        .roles
3406        .roles
3407        .iter()
3408        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
3409    {
3410        return Ok(Some(r.role_id.clone()));
3411    }
3412    if !create_if_missing {
3413        return Ok(None);
3414    }
3415    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3416    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3417    set_role(transport, community, &Role::admin(role_id.clone())).await?;
3418    Ok(Some(role_id))
3419}
3420
3421/// Grant the @admin role (minting it deterministically when absent), MERGED into
3422/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
3423/// blind push would erase their other roles. Owner-only: the position-1 Admin is
3424/// manageable only by position 0 (an equal never outranks it), and refusing
3425/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
3426/// entity from advancing this device's own floor onto a head readers reject.
3427pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3428    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
3429    // downstream publish's own (post-swap) guard write account A's floor into B.
3430    let session = SessionGuard::capture();
3431    let my_pk = me_pk()?;
3432    if my_pk != community.owner()? {
3433        return Err("only the community owner can grant @admin".to_string());
3434    }
3435    let view = fetch_authority(transport, community).await;
3436    if !session.is_valid() {
3437        return Err("account changed during grant".to_string());
3438    }
3439    let member_hex = member.to_hex();
3440    require_grant_head(community, &view, &member_hex)?;
3441    let role_id = ensure_admin_role(transport, community, &view, true)
3442        .await?
3443        .expect("create_if_missing yields an id");
3444    let mut role_ids = view
3445        .roles
3446        .grants
3447        .iter()
3448        .find(|g| g.member == member_hex)
3449        .map(|g| g.role_ids.clone())
3450        .unwrap_or_default();
3451    if role_ids.contains(&role_id) {
3452        return Ok(()); // already admin — don't bump the grant edition for nothing.
3453    }
3454    role_ids.push(role_id);
3455    grant_roles(transport, community, member, role_ids).await
3456}
3457
3458/// Strip the @admin role from the member's grant, preserving their other roles.
3459/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
3460pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3461    let session = SessionGuard::capture();
3462    let my_pk = me_pk()?;
3463    if my_pk != community.owner()? {
3464        return Err("only the community owner can revoke @admin".to_string());
3465    }
3466    let view = fetch_authority(transport, community).await;
3467    if !session.is_valid() {
3468        return Err("account changed during revoke".to_string());
3469    }
3470    let member_hex = member.to_hex();
3471    require_grant_head(community, &view, &member_hex)?;
3472    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3473        return Ok(()); // no admin role exists — nothing to revoke.
3474    };
3475    let mut role_ids = view
3476        .roles
3477        .grants
3478        .iter()
3479        .find(|g| g.member == member_hex)
3480        .map(|g| g.role_ids.clone())
3481        .unwrap_or_default();
3482    let before = role_ids.len();
3483    role_ids.retain(|r| r != &role_id);
3484    if role_ids.len() == before {
3485        return Ok(());
3486    }
3487    grant_roles(transport, community, member, role_ids).await
3488}
3489
3490/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
3491/// locally but no head folded (withheld / evicted): a blind push at that point
3492/// would erase their other roles at a higher version.
3493fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3494    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3495        return Err("malformed member key".to_string());
3496    };
3497    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3498    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3499        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3500    }
3501    Ok(())
3502}
3503
3504/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
3505/// whole list on every edit. Gated on the reader side by `BAN`.
3506pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3507    let session = SessionGuard::capture();
3508    super::roles::validate_banlist(banned)?;
3509    let content = super::roles::banlist_content_json(banned)?;
3510    let eid = super::derive::banlist_locator(community.id());
3511    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await
3512}
3513
3514/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
3515/// `MANAGE_METADATA`.
3516pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3517    let session = SessionGuard::capture();
3518    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3519    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3520    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3521}
3522
3523/// Persist a freshly-published icon/banner onto the held row and return the fresh
3524/// row. Reloads under the community's follow lock: `save_community_v2` is a
3525/// whole-row save that prunes channels absent from the passed struct, so writing
3526/// a stale pre-upload copy would drop rows a concurrent fold just landed.
3527pub async fn persist_community_image(
3528    id: &crate::community::CommunityId,
3529    img: control::ImageRef,
3530    is_banner: bool,
3531    session: &SessionGuard,
3532) -> Option<CommunityV2> {
3533    let lock = super::realtime::follow_lock(id);
3534    let _guard = lock.lock().await;
3535    if !session.is_valid() {
3536        return None;
3537    }
3538    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3539    if is_banner {
3540        fresh.banner = Some(img);
3541    } else {
3542        fresh.icon = Some(img);
3543    }
3544    crate::db::community::save_community_v2(&fresh).ok()?;
3545    Some(fresh)
3546}
3547
3548/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
3549/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
3550pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3551    let session = SessionGuard::capture();
3552    let my_pk = me_pk()?;
3553    ensure_channel_manager(community, &my_pk)?;
3554    let old_name = community.channel(channel_id).map(|c| c.name.clone());
3555    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
3556    // mint yet — refuse the flag flip rather than publish an edition no reader can
3557    // key (members would keep posting on the root-derived plane, splitting the
3558    // channel). Private → public works (readers heal to the root derivation).
3559    if meta.private {
3560        if let Some(held) = community.channel(channel_id) {
3561            if !held.private {
3562                return Err("converting a public channel to private is not supported yet".to_string());
3563            }
3564        }
3565    }
3566    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3567    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3568    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3569    // Apply locally too. The fold is the authority but runs later, so without this
3570    // an edit we just made reads back stale until some future control pass — the
3571    // rename appears to have silently failed.
3572    if !session.is_valid() {
3573        return Ok(());
3574    }
3575    if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3576        if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3577            ch.name = meta.name.clone();
3578            ch.private = meta.private;
3579            ch.voice = meta.voice;
3580            ch.meta_custom = meta.custom.clone();
3581            ch.meta_extra = meta.extra.clone();
3582            crate::db::community::save_community_v2(&held)?;
3583        }
3584    }
3585    // Keep the companion access role's label in step with the channel it gates.
3586    if meta.private {
3587        if let Some(old) = old_name.filter(|o| *o != meta.name) {
3588            rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3589        }
3590    }
3591    Ok(())
3592}
3593
3594/// Rename a private channel's companion access role to follow the channel (CORD-04 §2).
3595/// Best-effort and never fatal: the channel rename has already published, and a role's
3596/// name is cosmetic — entitlement is carried by the scope, not the label.
3597///
3598/// Only renames a label still equal to the channel's OLD name, so a deliberately
3599/// customised role name survives a channel rename untouched.
3600async fn rename_channel_access_role<T: Transport + ?Sized>(
3601    transport: &T,
3602    community: &CommunityV2,
3603    channel_id: &ChannelId,
3604    old_name: &str,
3605    new_name: &str,
3606    session: &SessionGuard,
3607) {
3608    let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3609        return;
3610    };
3611    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3612    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3613    // Fetched, not cached: `set_role` republishes the WHOLE role body, so a stale
3614    // cache would clobber a permission edit this client has not folded yet.
3615    let mut roster = fetch_authority(transport, community).await.roles;
3616    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3617    for r in cached.roles {
3618        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3619            roster.roles.push(r);
3620        }
3621    }
3622    if !session.is_valid() {
3623        return;
3624    }
3625    // MANAGE_CHANNELS got us the rename; the role edition needs MANAGE_ROLES + outrank
3626    // of its own. Publishing one readers reject would wedge our later, legitimate role
3627    // edits behind a rejected chain, so verify before publishing rather than after.
3628    let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3629    if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3630        return;
3631    }
3632    // Same selector `grant_channel_access` vends: the permission-less scoped role. A
3633    // per-channel moderator role sharing the scope is NOT the access list.
3634    let Some(mut role) = roster
3635        .channel_roles(&chan_hex)
3636        .into_iter()
3637        .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3638        .cloned()
3639    else {
3640        return;
3641    };
3642    if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3643        return;
3644    }
3645    role.name = new_name.to_string();
3646    if let Err(e) = set_role(transport, community, &role).await {
3647        crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3648        return;
3649    }
3650    if session.is_valid() {
3651        merge_local_roster(&cid_hex, Some(&role), None);
3652    }
3653}
3654
3655/// The local mirror of the reader's `MANAGE_CHANNELS` fold gate (CORD-03 §2): the
3656/// owner, or a roster-authorized manager who isn't banned. Refusing BEFORE any
3657/// publish keeps an unauthorized device from advancing its own edition floor onto
3658/// a head every reader rejects (wedging its later, legitimately-authorized edits
3659/// behind a rejected chain).
3660fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3661    let owner = community.owner()?;
3662    if *me == owner {
3663        return Ok(());
3664    }
3665    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3666    let me_hex = me.to_hex();
3667    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3668        return Err("you are banned from this community".to_string());
3669    }
3670    let roster = crate::db::community::get_community_roles(&cid_hex)?;
3671    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3672        Ok(())
3673    } else {
3674        Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3675    }
3676}
3677
3678/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
3679/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
3680/// Plane from the `community_root` (no per-channel key), so other members fold it in on
3681/// their next control follow with nothing to distribute. Returns the new channel id.
3682/// Reader-gated by `MANAGE_CHANNELS`.
3683pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3684    let channel_id = ChannelId(super::super::random_32());
3685    create_public_channel_with_id(transport, community, name, channel_id).await?;
3686    Ok(channel_id)
3687}
3688
3689/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3690/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
3691/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
3692pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3693    let session = SessionGuard::capture();
3694    // Serialize with the follow worker: the save below writes the WHOLE community
3695    // row from this caller's struct, so an unserialized concurrent follow adopting
3696    // a rotation would be rolled back to a stale root (a deaf community).
3697    let lock = super::realtime::follow_lock(community.id());
3698    let _guard = lock.lock().await;
3699    let my_pk = me_pk()?;
3700    ensure_channel_manager(community, &my_pk)?;
3701    assert_channel_id_free(&channel_id, community.id())?;
3702    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3703    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3704    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3705    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3706    if !session.is_valid() {
3707        return Err("account changed during channel create".to_string());
3708    }
3709    // Add locally + persist so the creator can post immediately (peers fold it in).
3710    let mut updated = community.clone();
3711    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() });
3712    crate::db::community::save_community_v2(&updated)?;
3713    Ok(())
3714}
3715
3716/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
3717/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
3718/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
3719/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
3720fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3721    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3722    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3723        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3724        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3725        if existing != mine
3726            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3727        {
3728            return Err("channel id is already live in another v2 community".to_string());
3729        }
3730    }
3731    Ok(())
3732}
3733
3734/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
3735/// random key at channel-epoch 1, mint a companion channel-scoped Role that is
3736/// the channel's access list (CORD-04 §2), deliver the key to the entitled over
3737/// the rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`).
3738/// Epoch 0 is the root generation ("the first privatisation is epoch 1"), so the
3739/// delivery commits its continuity to `(0, community_root)` — verifiable by every
3740/// member and bound to THIS community's root. The key ships BEFORE the
3741/// announcement: an aborted attempt leaves only an unannounced crate (invisible),
3742/// and a retry mints a fresh id, so there is no same-coordinate double-mint to
3743/// fork on. Live public links are refreshed; they carry no private key, so this
3744/// only re-states the public set.
3745pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3746    let channel_id = ChannelId(super::super::random_32());
3747    create_private_channel_with_id(transport, community, name, channel_id).await?;
3748    Ok(channel_id)
3749}
3750
3751/// The companion Role minted alongside a Private channel — the channel's access
3752/// list (CORD-04 §2 `scope: {"kind":"channel"}`). Same name as the channel, and
3753/// **no permission bits**: it confers read access, which is key possession, never
3754/// authority. Position sits below every management role for the same reason.
3755pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3756    use crate::community::roles::{Permissions, Role, RoleScope};
3757    Role {
3758        role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3759        name: name.to_string(),
3760        position: u32::MAX - 1,
3761        permissions: Permissions::empty(),
3762        scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3763        color: 0,
3764    }
3765}
3766
3767/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3768/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
3769pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3770    let session = SessionGuard::capture();
3771    // Serialize with the follow worker across the whole fetch→publish→save span
3772    // (the memberlist fetch is seconds long; an unserialized follow adopting a
3773    // rotation meanwhile would be rolled back by the whole-row save below).
3774    let lock = super::realtime::follow_lock(community.id());
3775    let _guard = lock.lock().await;
3776    let signer = crate::signer::active_signer()?;
3777    let my_pk = me_pk()?;
3778    ensure_channel_manager(community, &my_pk)?;
3779    assert_channel_id_free(&channel_id, community.id())?;
3780    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3781    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3782    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3783
3784    let channel_key = super::super::random_32();
3785    let epoch = Epoch(1);
3786
3787    // The channel's access list: a companion channel-scoped Role (CORD-04 §2),
3788    // granted to me so the creator is entitled from the first edition.
3789    let access_role = channel_access_role(&channel_id, name);
3790    let access_role_ids = vec![access_role.role_id.clone()];
3791
3792    // Recipients are the ENTITLED, not the memberlist: CORD-03's private channel
3793    // is "readable only by granted role-holders". At create that is me (plus the
3794    // owner, who is always entitled) — everyone else keys up when granted.
3795    let owner = community.owner()?;
3796    let mut recipients = vec![my_pk];
3797    if owner != my_pk {
3798        recipients.push(owner);
3799    }
3800    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3801    let mut blobs = Vec::with_capacity(recipients.len());
3802    for r in &recipients {
3803        blobs.push(
3804            rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3805                .await
3806                .map_err(|e| e.to_string())?,
3807        );
3808    }
3809    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3810    let at_secs = now_ms() / 1000;
3811    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())
3812        .await
3813        .map_err(|e| e.to_string())?;
3814    if !session.is_valid() {
3815        return Err("account changed during channel create".to_string());
3816    }
3817    for c in &chunks {
3818        transport.publish_durable(c, &community.relays).await?;
3819    }
3820    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3821    if !session.is_valid() {
3822        return Err("account changed during channel create".to_string());
3823    }
3824    // Publish the access list AFTER the channel exists, so a peer folding the
3825    // Role always resolves the channel it scopes to. A failure here leaves a
3826    // channel only its creator can read — recoverable by re-granting, never a
3827    // leak.
3828    set_role(transport, community, &access_role).await?;
3829    grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3830    if !session.is_valid() {
3831        return Err("account changed during channel create".to_string());
3832    }
3833    // The fold is the authority but runs later; without this the creator is not
3834    // yet entitled to their own channel and the next grant finds no access role.
3835    merge_local_roster(
3836        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3837        Some(&access_role),
3838        Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3839    );
3840    // A leave/delete raced the create: saving would resurrect the community row.
3841    if crate::db::community::community_protocol(community.id())?.is_none() {
3842        return Err("community removed during channel create".to_string());
3843    }
3844    let mut updated = community.clone();
3845    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() });
3846    crate::db::community::save_community_v2(&updated)?;
3847    // Archive the epoch-1 key so this channel's history stays readable across its
3848    // future rotations (CORD-03 §3).
3849    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3850    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3851    // Re-state live links. They carry no private key (CORD-05 §2 — a link's
3852    // audience holds no Role), so this only refreshes the public set.
3853    let _ = refresh_public_links(transport, &updated).await;
3854    Ok(())
3855}
3856
3857/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
3858// ── Receiving a key vend (CORD-03 "delivered on grant") ──────────────────────
3859
3860/// What a client should do with a vended Private-Channel key right now.
3861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3862pub enum VendVerdict {
3863    /// Every rule passed — adopt the key.
3864    Accept,
3865    /// Cannot judge YET: our fold lags the grant it delivers. Park quietly and
3866    /// re-judge after the next control follow. NOT an anomaly — a lagging fold
3867    /// is the normal case for a vend that races its own Grant.
3868    Park(&'static str),
3869    /// Judged invalid against evidence that cannot become true later. Alarm-worthy.
3870    Refuse(&'static str),
3871}
3872
3873/// Judge a vended Private-Channel key against our OWN folded state.
3874///
3875/// The Grant is the authority half and rides the owner-rooted control plane, so
3876/// it cannot be forged; the vend is only delivery. Acceptance therefore rests
3877/// entirely on what our own fold proves — a bundle can never introduce a channel
3878/// our control plane doesn't define, which is what closes the hidden-channel
3879/// injection class.
3880///
3881/// `community` must already be the held (self-certified) community: the caller
3882/// resolves it by `community_id`, so a bundle naming a community we're not in is
3883/// never judged here at all.
3884pub fn judge_channel_key_vend(
3885    community: &CommunityV2,
3886    roster: &crate::community::roles::CommunityRoles,
3887    channel_id: &ChannelId,
3888    epoch: Epoch,
3889    sender_hex: &str,
3890) -> VendVerdict {
3891    let me = match me_pk() {
3892        Ok(pk) => pk.to_hex(),
3893        Err(_) => return VendVerdict::Park("no active identity"),
3894    };
3895    let owner_hex = match community.owner() {
3896        Ok(o) => o.to_hex(),
3897        Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3898    };
3899    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3900
3901    // (2) The channel must exist in OUR fold, and be private there. The bundle's
3902    // own claims are ignored: a vend may deliver a key, never define a channel.
3903    let Some(ch) = community.channel(channel_id) else {
3904        return VendVerdict::Park("channel not in our fold yet");
3905    };
3906    if !ch.private {
3907        // Never heals: our owner-rooted fold says this channel is public, so a
3908        // "private key" for it is a spoof, not a lagging view.
3909        return VendVerdict::Refuse("vend names a channel our fold says is public");
3910    }
3911
3912    // (5) Epoch sanity, BOTH directions. Below is superseded by the rotation that
3913    // produced our copy. Above matters more: the channel head is monotonic, so a
3914    // wildly-ahead epoch is not merely wrong, it is PERMANENT — every genuine
3915    // rotation afterwards lands at `head + 1`, is refused as stale, and the
3916    // channel dies for us with no heal path at all (not a rekey, not a re-grant,
3917    // not a refound). Rotations advance one epoch at a time, so a lead this large
3918    // is never a delivery we could place.
3919    if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
3920        return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
3921    }
3922    if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
3923        return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
3924    }
3925
3926    // (3) OUR fold must show US granted a role scoped to this channel. This is
3927    // the rule that kills the spoof class: an attacker cannot forge the Grant,
3928    // so they cannot make us accept a key for a channel we were never granted.
3929    if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
3930        return VendVerdict::Park("our grant for this channel has not folded yet");
3931    }
3932
3933    // (4) The vendor must be entitled too — they hold the real key, so a wrong
3934    // key from them costs isolation, never confidentiality.
3935    if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
3936        return VendVerdict::Park("vendor's entitlement has not folded yet");
3937    }
3938
3939    VendVerdict::Accept
3940}
3941
3942/// How long an unprovable parked vend is kept. Deliberately long: the fallback
3943/// heal is the channel's next rotation, which may never come.
3944const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
3945
3946/// How far above our channel head a vend may claim to be. Generous — a keyless
3947/// cursor can lag a busy channel by many rotations — but bounded, because the
3948/// head is monotonic and an over-advance can never be walked back.
3949const MAX_VEND_EPOCH_LEAD: u64 = 1024;
3950
3951/// Re-judge every parked key vend for this community and adopt the ones that now
3952/// pass. Runs after a control follow (the fold moved, so verdicts can change) and
3953/// on the boot sweep.
3954///
3955/// Returns the channels newly keyed up.
3956pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
3957    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3958    let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
3959        Ok(p) if !p.is_empty() => p,
3960        _ => return Vec::new(),
3961    };
3962    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3963    let mut adopted = Vec::new();
3964    let now = now_ms() / 1000;
3965    for p in parked {
3966        // Several candidates may name one channel (parking is open to any sender,
3967        // so a stranger can never suppress the entitled vendor's key by holding a
3968        // slot). Once one is seated the rest are moot.
3969        if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
3970            let _ = crate::db::community::drop_pending_channel_key(p.id);
3971            continue;
3972        }
3973        // A vend we were never able to prove is not kept forever: an admin who
3974        // adds then immediately removes someone leaves a row nothing will ever
3975        // discharge. Generous by design — the alternative heal (the channel's
3976        // next rotation) can be arbitrarily far away, so this is hygiene, not a
3977        // deadline.
3978        if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
3979            let _ = crate::db::community::drop_pending_channel_key(p.id);
3980            continue;
3981        }
3982        let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
3983            let _ = crate::db::community::drop_pending_channel_key(p.id);
3984            continue;
3985        };
3986        let channel_id = ChannelId(id_bytes);
3987        match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
3988            VendVerdict::Accept => {
3989                if !session.is_valid() {
3990                    return adopted;
3991                }
3992                // First delivery vs rotation. A keyless channel must bypass the
3993                // monotonic guard: it sits at the epoch-0 cursor, and a peer that
3994                // mints born-private channels at epoch 0 vends that same epoch, so
3995                // `new > current` would refuse the only key on offer.
3996                let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
3997                let seated = if keyless {
3998                    crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
3999                } else {
4000                    crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4001                };
4002                if let Err(e) = seated {
4003                    crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4004                    continue;
4005                }
4006                // The key landed — every other candidate for this channel is moot.
4007                let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4008                adopted.push(channel_id);
4009            }
4010            VendVerdict::Refuse(why) => {
4011                crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4012                // Only THIS candidate — a sibling may still be the genuine vend.
4013                let _ = crate::db::community::drop_pending_channel_key(p.id);
4014            }
4015            // Quiet by design: the fold simply hasn't caught up.
4016            VendVerdict::Park(_) => {}
4017        }
4018    }
4019    adopted
4020}
4021
4022/// Grant `member` read access to a Private channel (CORD-03 "delivered on
4023/// grant"): publish a Grant adding the channel's access role, then vend the key
4024/// as a CORD-05 §6 Direct Invite whose bundle carries exactly the channels they
4025/// are now entitled to.
4026///
4027/// The Grant is the authority half and rides the owner-rooted control plane, so
4028/// it cannot be forged; the vend is only delivery. A recipient accepts the key
4029/// solely on the strength of their OWN fold showing this grant — the bundle can
4030/// never introduce a channel their control plane doesn't define.
4031pub async fn grant_channel_access<T: Transport + ?Sized>(
4032    transport: &T,
4033    community: &CommunityV2,
4034    channel_id: &ChannelId,
4035    member: &PublicKey,
4036) -> Result<(), String> {
4037    let session = SessionGuard::capture();
4038    let my_pk = me_pk()?;
4039    let ch = community.channel(channel_id).ok_or("unknown channel")?;
4040    if !ch.private {
4041        return Err("channel is public — every member already reads it".to_string());
4042    }
4043    if ch.key.is_none() {
4044        return Err("we hold no key for this channel, so we cannot vend it".to_string());
4045    }
4046    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4047    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4048    let owner_hex = community.owner()?.to_hex();
4049    // A Grant REPLACES the member's role set, so the union it is built from must
4050    // be CURRENT: a stale local roster would silently strip every role this
4051    // client hasn't folded yet. Fetch the authority fresh rather than trusting
4052    // the cache, and merge the local view on top so a role we just published
4053    // ourselves (which the plane has but no fold has read back) survives too.
4054    let mut roster = fetch_authority(transport, community).await.roles;
4055    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4056    for r in cached.roles {
4057        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4058            roster.roles.push(r);
4059        }
4060    }
4061    for g in cached.grants {
4062        if !roster.grants.iter().any(|x| x.member == g.member) {
4063            roster.grants.push(g);
4064        }
4065    }
4066    if !session.is_valid() {
4067        return Err("account changed during grant".to_string());
4068    }
4069    // Reader-gated by MANAGE_ROLES, like any Grant; narrowed to this channel so
4070    // a channel-scoped manager can run its own access list.
4071    if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4072        return Err("not authorized to manage this channel's access".to_string());
4073    }
4074    // The channel's roles are ordered by AUTHORITY, so `.first()` is the most
4075    // privileged — granting read access must never hand out a per-channel
4076    // moderator role that happens to share the scope. Pick the permission-less
4077    // one: conferring read access is exactly what carries no authority.
4078    let role_id = roster
4079        .channel_roles(&chan_hex)
4080        .into_iter()
4081        .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4082        .map(|r| r.role_id.clone())
4083        .ok_or("channel has no permission-less access role to grant")?;
4084
4085    let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4086    if !role_ids.contains(&role_id) {
4087        role_ids.push(role_id.clone());
4088    }
4089    grant_roles(transport, community, member, role_ids.clone()).await?;
4090    if !session.is_valid() {
4091        return Err("account changed during grant".to_string());
4092    }
4093    merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4094    // Settle the vend against the Grant we JUST published — the fold lags it.
4095    let bundle = bundle_of_with_overlay(
4096        community,
4097        BundleAudience::Member(*member),
4098        Some(my_pk),
4099        None,
4100        None,
4101        std::slice::from_ref(&role_id),
4102        &[],
4103    );
4104    let signer = crate::signer::active_signer()?;
4105    let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4106    if !session.is_valid() {
4107        return Err("account changed before vending the key".to_string());
4108    }
4109    transport.publish(&wrap, &community.relays).await?;
4110    Ok(())
4111}
4112
4113/// Revoke `member`'s read access to a Private channel (CORD-03 "rekeyed on
4114/// removal"): drop the channel's access role from their Grant, then rotate the
4115/// channel to its next epoch delivering the fresh key to everyone still
4116/// entitled (CORD-06). The revoked member keeps whatever history they already
4117/// read — a rekey protects the future, never the past.
4118pub async fn revoke_channel_access<T: Transport + ?Sized>(
4119    transport: &T,
4120    community: &CommunityV2,
4121    channel_id: &ChannelId,
4122    member: &PublicKey,
4123) -> Result<(), String> {
4124    let session = SessionGuard::capture();
4125    let my_pk = me_pk()?;
4126    let ch = community.channel(channel_id).ok_or("unknown channel")?;
4127    if !ch.private {
4128        return Err("channel is public — there is no access to revoke".to_string());
4129    }
4130    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4131    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4132    let owner_hex = community.owner()?.to_hex();
4133    // Same replace-not-merge hazard as the grant: the retained set must be built
4134    // from a CURRENT roster or this revoke strips roles we simply hadn't folded.
4135    let mut roster = fetch_authority(transport, community).await.roles;
4136    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4137    for r in cached.roles {
4138        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4139            roster.roles.push(r);
4140        }
4141    }
4142    for g in cached.grants {
4143        if !roster.grants.iter().any(|x| x.member == g.member) {
4144            roster.grants.push(g);
4145        }
4146    }
4147    if !session.is_valid() {
4148        return Err("account changed during revoke".to_string());
4149    }
4150    if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4151        return Err("not authorized to manage this channel's access".to_string());
4152    }
4153    if *member == community.owner()? {
4154        return Err("the owner is supreme and cannot be removed".to_string());
4155    }
4156    let access_ids = roster.channel_role_ids(&chan_hex);
4157    // Without the access list this revoke is a no-op that still ROTATES, and the
4158    // rotation's recipient filter would match nobody — cutting off every
4159    // legitimately entitled member. Refuse rather than mass-evict.
4160    if access_ids.is_empty() {
4161        return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4162    }
4163    let remaining: Vec<String> = roster
4164        .roles_of(&member.to_hex())
4165        .map(|r| r.role_id.clone())
4166        .filter(|id| !access_ids.contains(id))
4167        .collect();
4168    grant_roles(transport, community, member, remaining.clone()).await?;
4169    if !session.is_valid() {
4170        return Err("account changed during revoke".to_string());
4171    }
4172    merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4173    // Rotate so the removal actually severs them (CORD-06 §1). The revoked
4174    // member is excluded from the recipient set by the overlay, since the fold
4175    // has not yet caught the Grant we just published.
4176    rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4177}
4178
4179/// Rotate one Private channel to its next epoch, delivering the fresh key to
4180/// everyone entitled EXCEPT `removed` (CORD-06 §1 single-channel rekey).
4181///
4182/// `roster` must be the caller's CURRENT view (fetched, not the local cache):
4183/// the recipient set is built from it, so a cached roster silently drops every
4184/// member granted since this client last folded — they keep a dead key with no
4185/// heal path. `access_ids` is that roster's access-role set for this channel;
4186/// `removed` is excluded explicitly, since the revoking Grant was published
4187/// moments ago and no fold has caught it.
4188async fn rekey_channel_excluding<T: Transport + ?Sized>(
4189    transport: &T,
4190    community: &CommunityV2,
4191    channel_id: &ChannelId,
4192    roster: &crate::community::roles::CommunityRoles,
4193    access_ids: &[String],
4194    removed: &PublicKey,
4195) -> Result<(), String> {
4196    let session = SessionGuard::capture();
4197    // Whole-row save below — serialize with the follow worker (see create_*_channel).
4198    let lock = super::realtime::follow_lock(community.id());
4199    let _guard = lock.lock().await;
4200    let signer = crate::signer::active_signer()?;
4201    let my_pk = me_pk()?;
4202    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4203    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4204    let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4205    let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4206    let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4207    let owner = community.owner()?;
4208    let owner_hex = owner.to_hex();
4209
4210    // Everyone still entitled: the owner (always), me (the rotator must be able
4211    // to read what it rekeys), and every member the roster shows holding an
4212    // access role — minus the removal.
4213    let removed_hex = removed.to_hex();
4214    let mut recipients: Vec<PublicKey> = vec![my_pk];
4215    if owner != my_pk {
4216        recipients.push(owner);
4217    }
4218    for g in &roster.grants {
4219        if g.member == removed_hex || g.member == owner_hex {
4220            continue;
4221        }
4222        if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4223            continue;
4224        }
4225        if let Ok(pk) = PublicKey::parse(&g.member) {
4226            if !recipients.contains(&pk) {
4227                recipients.push(pk);
4228            }
4229        }
4230    }
4231    // Mint-or-reuse keyed by (channel, next epoch) so a retry after a partial
4232    // publish re-uses the same key instead of forking the epoch.
4233    let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4234    let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4235    let mut blobs = Vec::with_capacity(recipients.len());
4236    for r in &recipients {
4237        blobs.push(
4238            rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4239                .await
4240                .map_err(|e| e.to_string())?,
4241        );
4242    }
4243    let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4244    let at_secs = now_ms() / 1000;
4245    let chunks = rekey::build_rekey_chunks(&signer, my_pk, &group, RekeyScope::Channel(*channel_id), new_epoch, ch.epoch, &prev_commit, &blobs, at_secs, my_authority_citation(community, &my_pk).as_ref())
4246        .await
4247        .map_err(|e| e.to_string())?;
4248    if !session.is_valid() {
4249        return Err("account changed during channel rekey".to_string());
4250    }
4251    for c in &chunks {
4252        transport.publish_durable(c, &community.relays).await?;
4253    }
4254    if !session.is_valid() {
4255        return Err("account changed during channel rekey".to_string());
4256    }
4257    if crate::db::community::community_protocol(community.id())?.is_none() {
4258        return Err("community removed during channel rekey".to_string());
4259    }
4260    // Adopt locally + archive, so our own history reads across the rotation.
4261    crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4262    crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4263    Ok(())
4264}
4265
4266/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
4267/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
4268pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4269    let session = SessionGuard::capture();
4270    // Whole-row save below — serialize with the follow worker (see create_*_channel).
4271    let lock = super::realtime::follow_lock(community.id());
4272    let _guard = lock.lock().await;
4273    let my_pk = me_pk()?;
4274    ensure_channel_manager(community, &my_pk)?;
4275    // The tombstone carries the FULL held document (deleted flag set): a strict
4276    // reader treats an edition as the entity, so even a deletion must not strip
4277    // fields it didn't touch (CORD-02 §6).
4278    let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4279        name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4280    });
4281    meta.deleted = Some(true);
4282    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4283    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4284    if !session.is_valid() {
4285        return Err("account changed during channel delete".to_string());
4286    }
4287    let mut updated = community.clone();
4288    updated.channels.retain(|c| c.id.0 != channel_id.0);
4289    crate::db::community::save_community_v2(&updated)?;
4290    Ok(())
4291}
4292
4293// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
4294
4295/// Re-fold this community's Control Plane and apply the current metadata +
4296/// **public** channel set to the held community, persisting any change. Called
4297/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
4298/// edited description) so a long-running bot tracks the community mid-session
4299/// instead of freezing at its join-time view.
4300///
4301/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
4302/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
4303/// edition is eligible only if its signer CURRENTLY holds the entity's management
4304/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
4305/// a demoted one's drop. The owner is supreme, proven by the self-certifying
4306/// community_id (no network trust).
4307///
4308/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
4309/// delivered over the rekey plane (or an invite bundle), never derivable from a
4310/// control edition alone. A new Private channel therefore surfaces only once
4311/// [`follow_rekeys`] delivers its key. Public channels derive from the
4312/// community_root, so they fold in directly.
4313///
4314/// Returns the updated community iff something changed (so the caller can skip a
4315/// redundant re-subscribe + refresh notification).
4316pub async fn follow_control<T: Transport + ?Sized>(
4317    transport: &T,
4318    community: &CommunityV2,
4319    session: &SessionGuard,
4320) -> Result<Option<CommunityV2>, String> {
4321    community.owner()?; // fail fast if the community is somehow unproven.
4322    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4323    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4324
4325    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
4326    // under a prior epoch is excluded, so that entity auto-bootstraps after a
4327    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
4328    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
4329    // window the floor exists to shut.
4330    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4331        .into_iter()
4332        .filter(|(_, f)| f.0 == community.root_epoch.0)
4333        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4334        .collect();
4335
4336    // Newest window first; page OLDER only while a tracking entity is gapped (its
4337    // floor link evicted from the window — H1/M8 refetch), bounded like the join
4338    // verifier. A withholding relay still converges to fail-closed after the cap.
4339    let mut editions: Vec<ParsedEdition> = Vec::new();
4340    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4341    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4342    let mut oldest: Option<u64> = None;
4343    let mut until: Option<u64> = None;
4344    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
4345    let mut authority = AuthoritySet::owner_only();
4346    // Whether this round gave up with editions still unread. The follow is
4347    // procedural by design — process what arrives, converge with everyone else —
4348    // so a short read never blocks reading, writing or epoch adoption. It only
4349    // withholds the ROSTER cache below: caching a partial authority as this
4350    // device's baseline is the one step that outlives the round.
4351    let mut truncated = true;
4352    for _ in 0..FOLLOW_MAX_PAGES {
4353        // Quorum, DECLARED (the until→Full transport floor is gone): these
4354        // control reads tolerate a partial union — their fold semantics are
4355        // fail-safe on gaps (seeded banlists, withheld roster cache).
4356        let query = Query {
4357            kinds: vec![stream::KIND_WRAP],
4358            authors: vec![control.pk_hex()],
4359            until,
4360            limit: Some(FOLLOW_PAGE),
4361            evidence: crate::community::transport::Evidence::Quorum,
4362            ..Default::default()
4363        };
4364        let wraps = transport.fetch(&query, &community.relays).await?;
4365        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
4366        // at a page boundary); the wrap-id dedup makes re-served boundary events
4367        // free, and a page with nothing new means the relay is exhausted.
4368        let mut fresh = 0usize;
4369        for w in &wraps {
4370            if !seen_wraps.insert(w.id) {
4371                continue;
4372            }
4373            fresh += 1;
4374            let at = w.created_at.as_secs();
4375            if oldest.is_none_or(|o| at < o) {
4376                oldest = Some(at);
4377            }
4378            // Open + seal-verify every edition; authority is resolved by the roster
4379            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
4380            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4381                if seen.insert(ed.inner_id) {
4382                    editions.push(ed);
4383                }
4384            }
4385        }
4386        // Roster first (roles/grants/banlist → authorized set), then the authority-
4387        // gated metadata/channel fold over the same edition set.
4388        authority = fold_authority(community, &editions, &floors);
4389        fold = apply_control_fold(community, &editions, &floors, &authority);
4390        if !(fold.gapped || authority.gapped) {
4391            truncated = false; // nothing is gapped: this view is coherent
4392            break;
4393        }
4394        if fresh == 0 {
4395            // A FULL page with nothing new is a same-second wall no `until` steps
4396            // past, so older editions stay unreachable; a short page is the end
4397            // of the plane, and a gap in THAT is the relay withholding, not us
4398            // giving up early.
4399            truncated = wraps.len() >= FOLLOW_PAGE;
4400            break;
4401        }
4402        until = oldest;
4403    }
4404
4405    // The fetches straddled awaits; a swap since the guard was captured must not
4406    // write account A's control state into B.
4407    if !session.is_valid() {
4408        return Err("account changed during control follow".to_string());
4409    }
4410    // A leave/delete raced this follow: writing now would resurrect the community
4411    // row and orphan floor rows past delete_community's wipe.
4412    if crate::db::community::community_protocol(community.id())?.is_none() {
4413        return Ok(None);
4414    }
4415    // Persist advanced floors BEFORE the state save (a failed floor write must not
4416    // let saved state outrun its floor), stamping the epoch this fold ran under —
4417    // not the row's write-time value, which a concurrent re-founding can bump. Both
4418    // the metadata/channel heads and the roster/banlist heads advance their floors;
4419    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
4420    for h in fold.heads.iter().chain(authority.heads.iter()) {
4421        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)?;
4422        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)?;
4423    }
4424    // Persist the authorized banlist content (retained/withholding folds carry None,
4425    // so the stored banlist is left intact — an anti-roster never silently un-bans).
4426    let mut authority_changed = false;
4427    // Ban marks MERGE (never replace): they must outlive both the ban and this window,
4428    // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
4429    // itself was retained — the history is what the suppression reads.
4430    let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4431    if let Some((banned, version)) = &authority.banlist_persist {
4432        let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4433        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4434        let mut after = banned.clone();
4435        before.sort();
4436        after.sort();
4437        authority_changed |= before != after;
4438    }
4439    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
4440    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
4441    // fetch path: only an aggregate built from roster editions at least as new as
4442    // the stored one may replace it — a withholding relay serving NO roster
4443    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
4444    // and that must RETAIN the stored roster, never wipe standing.
4445    let newest_roster_at: i64 = editions
4446        .iter()
4447        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4448        .map(|e| e.created_at as i64)
4449        .max()
4450        .unwrap_or(0);
4451    // Completeness gate: the `gapped` flag only covers entities present in the window.
4452    // A role/grant floored on this device but with ZERO editions fetched (aged out of
4453    // the paging reach) folds absent yet raises no gap — persisting would silently drop
4454    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
4455    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
4456    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4457    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4458    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4459        && stored.grants.iter().all(|g| {
4460            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4461                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4462                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4463            })
4464        });
4465    // `truncated` covers the case the other three can't: a COLD device (no floors,
4466    // no stored roster) folding under a plane a member has inflated past the pager.
4467    // `stored_complete` is trivially true with nothing stored, so without this the
4468    // first sync would cache a partial authority as its own baseline.
4469    if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4470        authority_changed |= stored != authority.roles;
4471        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4472    }
4473    // Cache the folded invite Registry so Public/Private stays a sync LOCAL read
4474    // (v1 parity — `invite_registry` is the column every caller reads). Gated like
4475    // the roster: a truncated or gapped window folds an empty registry out of mere
4476    // absence, and persisting that under-states Public — the unsafe direction, since
4477    // it leaves a live link open behind a ban.
4478    if !truncated && !authority.gapped && !fold.gapped {
4479        if let Ok(owner) = community.owner() {
4480            let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4481            let live = flatten_link_sets(&sets);
4482            let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4483            before.sort();
4484            if before != live {
4485                crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4486                authority_changed = true;
4487            }
4488            // The per-creator split drives "X has N active invite links" and the
4489            // first-link-flips-Public confirm; it lives in its own table.
4490            crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4491        }
4492    }
4493    // Roster/banlist moves are invisible in the returned community (they live in
4494    // their own columns), so callers that key a refresh off `updated` would never
4495    // repaint a promote/demote/ban. Announce from the single fold point — it covers
4496    // realtime, boot catch-up and manual sync alike.
4497    if authority_changed {
4498        crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4499    }
4500    match fold.updated {
4501        Some(u) => {
4502            crate::db::community::save_community_v2(&u)?;
4503            Ok(Some(u))
4504        }
4505        None => Ok(None),
4506    }
4507}
4508
4509/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
4510/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
4511///
4512/// Nearly free to raise: both follow loops exit the moment the fold stops being
4513/// gapped, so the cap only binds when something is genuinely missing — exactly
4514/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
4515/// under a plane that 100 roles + 400 grants already outgrows before counting
4516/// superseded versions, which accumulate until a compaction retires them.
4517const FOLLOW_MAX_PAGES: usize = 32;
4518const FOLLOW_PAGE: usize = 500;
4519/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
4520/// every Control Event must abort). Far above any real plane, but plane depth is
4521/// attacker-controlled — any member holds the key that mints wraps — so the read
4522/// is bounded and reports coming up short rather than compacting a partial view.
4523const COMPACT_MAX_PAGES: usize = 512;
4524
4525/// A folded control head to persist as the per-entity refuse-downgrade floor.
4526#[derive(Clone)]
4527struct FoldedHead {
4528    entity_hex: String,
4529    version: u64,
4530    self_hash: [u8; 32],
4531    inner_id: [u8; 32],
4532}
4533
4534/// The outcome of a floor-aware control fold: the updated community (if content
4535/// changed), the heads to persist as the new floor (returned even when content is
4536/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
4537/// hit an unresolvable gap — the caller's signal to page older history and re-fold
4538/// (CORD-04 H1/M8's refetch).
4539struct ControlFold {
4540    updated: Option<CommunityV2>,
4541    heads: Vec<FoldedHead>,
4542    gapped: bool,
4543}
4544
4545/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
4546type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4547
4548/// Fold owner-authored control editions into an updated community using the
4549/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
4550/// [`version::fold`]`(floor, floor_hash)`:
4551///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
4552///     doesn't block the verified prefix — refuse-downgrade holds for everything
4553///     applied — but flags `gapped` so the caller pages for the rest.
4554///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
4555///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
4556///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
4557///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
4558///     client lands on the same head where a hash-strict floor would wedge forever.
4559///     Anything else is withholding → fail closed + `gapped`.
4560///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
4561///     Refounding, since the caller epoch-filters the floor) takes the highest
4562///     signed head (author already owner-filtered).
4563/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
4564/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
4565/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
4566/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
4567/// convergence preserved).
4568fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4569    use crate::community::roles::Permissions;
4570    use std::collections::BTreeMap;
4571
4572    let owner_hex = community.owner().ok().map(|o| o.to_hex());
4573
4574    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4575    for e in editions {
4576        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4577    }
4578
4579    let mut out = community.clone();
4580    let mut changed = false;
4581    let mut heads = Vec::new();
4582    let mut gapped = false;
4583    for ((vsk_code, eid), group) in &groups {
4584        // This fold applies exactly two entities: community metadata (eid ==
4585        // community_id) and channel metadata. A vsk-2 whose eid equals the community
4586        // id is excluded — the floor row keys on the entity alone, so it would share
4587        // (and corrupt) the metadata chain's floor.
4588        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4589        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4590        if !is_meta && !is_channel {
4591            continue;
4592        }
4593        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
4594        // entity's management bit are eligible. Pre-filtering before the fold means a
4595        // demoted admin's (possibly higher-version) edition can't be the head; the
4596        // highest AUTHORIZED head wins. The owner is supreme.
4597        let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
4598        let authed: Vec<&ParsedEdition> = group
4599            .iter()
4600            .copied()
4601            .filter(|e| {
4602                let author = e.author.to_hex();
4603                // A banned npub's edits are dropped (CORD-04 §4), even if they still
4604                // held a bit via a not-yet-stripped grant.
4605                !authority.banned.contains(&author)
4606                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4607                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
4608                    // this same fold settled, so it works on a bootstrap where no
4609                    // persisted head exists yet.
4610                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4611            })
4612            .collect();
4613        if authed.is_empty() {
4614            continue;
4615        }
4616        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4617        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4618        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4619        gapped |= entity_gapped;
4620        let Some(hi) = hi else { continue };
4621
4622        let head = authed[hi];
4623        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4624        if is_meta {
4625            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4626                changed |= apply_community_metadata(&mut out, meta);
4627            }
4628        } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4629            // vsk-2 carries no community binding (shared v1 grammar); a same-owner
4630            // cross-community replay can inject a phantom PUBLIC channel (bounded:
4631            // root-scoped key, eids don't collide). Binding is a deferred wire change.
4632            changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4633        }
4634    }
4635    ControlFold { updated: changed.then_some(out), heads, gapped }
4636}
4637
4638/// Fold one entity's editions against its persisted floor into a head index (into the
4639/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
4640/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
4641/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
4642/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
4643/// the lower-inner-id winner; and fail closed otherwise.
4644fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4645    let floor_v = floor.map(|f| f.0).unwrap_or(0);
4646    if floor_v == 0 {
4647        return (version::bootstrap_head(fold_eds, 0), false);
4648    }
4649    let floor_hash = floor.map(|f| &f.1);
4650    let held_inner = floor.and_then(|f| f.2);
4651    let result = version::fold(fold_eds, floor_v, floor_hash);
4652    if result.anchored {
4653        return (result.head, result.gap); // verified prefix; page any upper gap.
4654    }
4655    if result.head.is_none() && !result.gap {
4656        return (None, false); // everything below floor — a stale relay, no paging.
4657    }
4658    // Unanchored under a held floor: converge a same-version fork at the floor to its
4659    // deterministic winner (lower inner id; a NULL held id is always replaceable),
4660    // else fail closed.
4661    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4662    let win_hash = match fork {
4663        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4664        _ => return (None, true), // detached from our committed head → withholding.
4665    };
4666    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4667    if !re.anchored {
4668        return (None, true);
4669    }
4670    (re.head, re.gap)
4671}
4672
4673/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
4674/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
4675/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
4676/// recomputed from the self-certifying community_id at each use.
4677struct AuthoritySet {
4678    roles: crate::community::roles::CommunityRoles,
4679    banned: std::collections::BTreeSet<String>,
4680    heads: Vec<FoldedHead>,
4681    gapped: bool,
4682    /// The authorized banlist `(content, version)` to persist when an authorized head
4683    /// advanced the floor. `None` when the banlist was retained (no new authorized
4684    /// head) or is empty — the caller then leaves the stored banlist untouched.
4685    banlist_persist: Option<(Vec<String>, u64)>,
4686    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
4687    /// named them, across every edition in the window rather than just the head. Outlives
4688    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
4689    banned_at: std::collections::BTreeMap<String, u64>,
4690}
4691
4692impl AuthoritySet {
4693    /// Bootstrap authority for a community with no roster editions folded yet: only
4694    /// the owner is authorized (supreme), nobody banned.
4695    fn owner_only() -> Self {
4696        AuthoritySet {
4697            roles: Default::default(),
4698            banned: Default::default(),
4699            heads: vec![],
4700            gapped: false,
4701            banlist_persist: None,
4702            banned_at: Default::default(),
4703        }
4704    }
4705}
4706
4707/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
4708/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
4709/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
4710/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
4711/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
4712/// banlist is enforced only if its head's signer held BAN in the authorized roster.
4713fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4714    use crate::community::roles::Permissions;
4715    use std::collections::BTreeMap;
4716
4717    let cid = community.id();
4718    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4719    let owner = community.owner().ok();
4720    let owner_hex = owner.map(|o| o.to_hex());
4721    let banlist_eid = super::derive::banlist_locator(cid);
4722    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4723
4724    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4725    for e in editions {
4726        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4727            groups.entry(e.entity_id).or_default().push(e);
4728        }
4729    }
4730
4731    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
4732    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
4733    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
4734    // to the next candidate, so a forged higher-version edition can't suppress the
4735    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
4736    // let any member vanish a role or a member's grant). `gapped` (drives older-
4737    // paging) stays fold_head's per-entity flag.
4738    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4739    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4740    let mut gapped = false;
4741
4742    for (eid, group) in &groups {
4743        // The banlist is folded author-aware AFTER the roster is known (below).
4744        if *eid == banlist_eid {
4745            continue;
4746        }
4747        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4748        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
4749        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4750        gapped |= entity_gapped;
4751        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
4752
4753        for p in group {
4754            // Refuse-downgrade: never consider an edition below the persisted floor.
4755            if p.version < floor_v {
4756                continue;
4757            }
4758            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
4759            match p.vsk.as_str() {
4760                vsk::ROLE => {
4761                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
4762                    if let Some(role) = super::roles::parse_role_content(&p.content) {
4763                        if role.role_id == entity_hex && role.position != 0 {
4764                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
4765                        }
4766                    }
4767                }
4768                vsk::GRANT => {
4769                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
4770                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
4771                            if super::derive::grant_locator(cid, &member) == *eid {
4772                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
4773                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
4774                            }
4775                        }
4776                    }
4777                }
4778                _ => {}
4779            }
4780        }
4781    }
4782    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
4783        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4784    }
4785
4786    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4787    // Preliminary roster (bans not yet applied) — the authority view the banlist head
4788    // is judged against.
4789    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4790
4791    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
4792    // both closed:
4793    //   - head selection: the head is the highest version whose author CURRENTLY holds
4794    //     BAN — an unauthorized higher-version edition can't erase existing bans
4795    //     (fail-open), and the floor never advances to one;
4796    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
4797    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
4798    //     owner is unbannable);
4799    //   - withholding: when no authorized head is served, the persisted banlist is
4800    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
4801    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4802    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
4803    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
4804    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
4805    // head eligibility, not just from the roster.
4806    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4807    let banlist_authored: Vec<&ParsedEdition> = groups
4808        .get(&banlist_eid)
4809        .map(|g| {
4810            g.iter()
4811                .copied()
4812                .filter(|e| {
4813                    let ah = e.author.to_hex();
4814                    !banned_authors.contains(ah.as_str())
4815                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4816                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4817                })
4818                .collect()
4819        })
4820        .unwrap_or_default();
4821    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
4822    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
4823    // head alone forgets the ban that the suppression exists to remember. The owner is
4824    // skipped: they are never bannable, and a moderator listing them must not durably
4825    // suppress them past the un-ban.
4826    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4827    for p in &banlist_authored {
4828        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4829            if owner_hex.as_deref() == Some(t.as_str()) {
4830                continue;
4831            }
4832            let slot = banned_at.entry(t).or_insert(0);
4833            *slot = (*slot).max(p.created_at);
4834        }
4835    }
4836    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4837    let mut banlist_head: Option<FoldedHead> = None;
4838    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4839        persisted_banned.into_iter().collect()
4840    } else {
4841        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4842        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4843        gapped |= g;
4844        match hi {
4845            Some(hi) => {
4846                let head = banlist_authored[hi];
4847                let ah = head.author.to_hex();
4848                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4849                    .unwrap_or_default()
4850                    .into_iter()
4851                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4852                    .take(super::roles::MAX_BANLIST)
4853                    .collect();
4854                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4855                banlist_persist = Some((list.clone(), head.version));
4856                list.into_iter().collect()
4857            }
4858            None => persisted_banned.into_iter().collect(),
4859        }
4860    };
4861
4862    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
4863    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
4864    // the banned set excluded so a banned admin loses authority.
4865    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
4866    if let Some(bh) = banlist_head {
4867        heads.push(bh);
4868    }
4869
4870    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
4871    // authorization, so an attacker's unauthorized roles can't consume cap slots and
4872    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
4873    // roles empty the roster).
4874    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
4875        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
4876        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
4877        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
4878        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
4879        authorized.grants.retain(|g| !g.role_ids.is_empty());
4880    }
4881
4882    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
4883}
4884
4885/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
4886/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
4887struct AuthorityCand {
4888    role: Option<crate::community::roles::Role>,
4889    grant: Option<crate::community::roles::MemberGrant>,
4890    author: PublicKey,
4891    head: FoldedHead,
4892    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
4893    /// (supreme, cites nothing) or an uncited one — the latter is refused.
4894    citation: Option<crate::community::edition::AuthorityCitation>,
4895}
4896
4897/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
4898///
4899/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
4900/// pass that establishes those heads, so an external floor would refuse every
4901/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
4902/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
4903/// authority-verified head as its baseline, a tracking client fails closed per
4904/// entity — applied to the citation instead of the chain link.
4905fn citation_ok_in_fold(
4906    cid: &crate::community::CommunityId,
4907    heads: &[FoldedHead],
4908    owner_hex: Option<&str>,
4909    author: &PublicKey,
4910    citation: Option<&crate::community::edition::AuthorityCitation>,
4911) -> bool {
4912    let actor_hex = author.to_hex();
4913    if owner_hex == Some(actor_hex.as_str()) {
4914        return true;
4915    }
4916    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
4917    let as_entity: Vec<crate::community::roster::EntityHead> = heads
4918        .iter()
4919        .map(|h| crate::community::roster::EntityHead {
4920            entity_hex: h.entity_hex.clone(),
4921            version: h.version,
4922            self_hash: h.self_hash,
4923            inner_id: h.inner_id,
4924            citation: None,
4925        })
4926        .collect();
4927    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
4928}
4929
4930/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
4931/// takes the highest-version candidate whose author is authorized to author it under
4932/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
4933/// vanishing the entity. Authority resolves outward from the owner (proven by
4934/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
4935/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
4936/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
4937/// advances only to authorized heads — an unauthorized forgery never poisons it).
4938fn select_authorized(
4939    cid: &crate::community::CommunityId,
4940    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4941    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
4942    owner_hex: Option<&str>,
4943    excluded: &std::collections::BTreeSet<String>,
4944) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
4945    use crate::community::roles::{CommunityRoles, Permissions};
4946    let mut accepted = CommunityRoles::default();
4947    let mut heads: Vec<FoldedHead> = Vec::new();
4948    // Jacobi iteration: authority propagates one delegation level per round, so a
4949    // generous multiple of the entity count is an ample bound. Non-convergence (never
4950    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
4951    // are ever selected.
4952    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
4953    for _ in 0..bound {
4954        let mut next = CommunityRoles::default();
4955        let mut next_heads: Vec<FoldedHead> = Vec::new();
4956
4957        for cands in role_cands.values() {
4958            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
4959            // is necessary but not sufficient: an edition REPLACES the entity, so
4960            // the author must also outrank the position standing before it.
4961            // Without that, an admin at position 5 rewrites the position-1 role
4962            // to position 9 — every check passes, since 9 is beneath them — and
4963            // a role that outranked them is now beneath them, along with everyone
4964            // holding it. Rank inversion by republish.
4965            //
4966            // The chain is replayed ASCENDING so each version is judged against
4967            // the position its own predecessor established, then the highest
4968            // admissible version wins (candidates arrive version-DESC, forks
4969            // broken by lowest inner_id — preserved by walking version groups).
4970            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4971            let mut standing: Option<u32> = None;
4972            let mut i = cands.len();
4973            while i > 0 {
4974                let hi = i;
4975                let ver = cands[i - 1].head.version;
4976                while i > 0 && cands[i - 1].head.version == ver {
4977                    i -= 1;
4978                }
4979                // One winner per version: fork siblings can't sidestep the gate.
4980                for c in cands[i..hi].iter().rev() {
4981                    let Some(role) = &c.role else { continue };
4982                    let ah = c.author.to_hex();
4983                    if excluded.contains(&ah) || role.position == 0 {
4984                        continue;
4985                    }
4986                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
4987                        continue;
4988                    }
4989                    if let Some(prev) = standing {
4990                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
4991                            continue;
4992                        }
4993                    }
4994                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
4995                        continue;
4996                    }
4997                    admissible.insert(c.head.self_hash);
4998                    standing = Some(role.position);
4999                    break;
5000                }
5001            }
5002            for c in cands {
5003                let Some(role) = &c.role else { continue };
5004                if !admissible.contains(&c.head.self_hash) {
5005                    continue;
5006                }
5007                next.roles.push(role.clone());
5008                next_heads.push(c.head.clone());
5009                break; // highest admissible candidate for this entity
5010            }
5011        }
5012        for cands in grant_cands.values() {
5013            for c in cands {
5014                let Some(grant) = &c.grant else { continue };
5015                let ah = c.author.to_hex();
5016                if excluded.contains(&ah) || excluded.contains(&grant.member) {
5017                    continue;
5018                }
5019                // The granter must outrank every granted role (resolved against the
5020                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
5021                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5022                let Some(positions) = positions else { continue };
5023                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5024                    continue;
5025                }
5026                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5027                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5028                {
5029                    // Record the head even for an EMPTY grant (a revoke is a real chain
5030                    // advance a completeness check must see), but don't carry the husk
5031                    // into the roster.
5032                    next_heads.push(c.head.clone());
5033                    if !grant.role_ids.is_empty() {
5034                        next.grants.push(grant.clone());
5035                    }
5036                    break;
5037                }
5038            }
5039        }
5040
5041        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5042        accepted = next;
5043        heads = next_heads;
5044        if converged {
5045            break;
5046        }
5047    }
5048    (accepted, heads)
5049}
5050
5051/// Apply a folded community-metadata head. Relays only overwrite when the edition
5052/// carries a non-empty list (a metadata edition that omits relays must not blank
5053/// the working set). Returns whether anything changed.
5054fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5055    let mut changed = false;
5056    if out.name != meta.name {
5057        out.name = meta.name;
5058        changed = true;
5059    }
5060    if out.description != meta.description {
5061        out.description = meta.description;
5062        changed = true;
5063    }
5064    // Icon/banner apply verbatim, None included — an edition is the full
5065    // document, so an absent image IS a removal (editors preserve via
5066    // `CommunityV2::metadata()`).
5067    if out.icon != meta.icon {
5068        out.icon = meta.icon;
5069        changed = true;
5070    }
5071    if out.banner != meta.banner {
5072        out.banner = meta.banner;
5073        changed = true;
5074    }
5075    // Client-extensible + unknown fields ride the fold verbatim so our own
5076    // editions can carry them forward (CORD-02 §6).
5077    if out.meta_custom != meta.custom {
5078        out.meta_custom = meta.custom;
5079        changed = true;
5080    }
5081    if out.meta_extra != meta.extra {
5082        out.meta_extra = meta.extra;
5083        changed = true;
5084    }
5085    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
5086    // other construction boundary, and the fold is a boundary like any other: an
5087    // authorized editor is not a trusted one, and an oversize list costs every
5088    // member a fan-out on each publish and the slowest of N on each fetch
5089    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
5090    // CAPPED list too — against the raw one, an oversize edition never compares
5091    // equal, so every fold would report a change and re-save forever.
5092    let relays = crate::community::cap_relays(meta.relays);
5093    if !relays.is_empty() && out.relays != relays {
5094        out.relays = relays;
5095        changed = true;
5096    }
5097    changed
5098}
5099
5100/// Apply a folded channel-metadata head: delete removes the channel, a rename
5101/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
5102/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
5103/// rekey plane or a fresh bundle). Returns whether anything changed.
5104fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5105    let deleted = meta.deleted.unwrap_or(false);
5106    if deleted {
5107        let before = out.channels.len();
5108        out.channels.retain(|c| c.id.0 != id.0);
5109        return out.channels.len() != before;
5110    }
5111    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5112        Some(existing) => {
5113            let mut changed = false;
5114            if existing.name != meta.name {
5115                existing.name = meta.name;
5116                changed = true;
5117            }
5118            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
5119            // local edit republishes them instead of wiping (CORD-02 §6).
5120            if existing.voice != meta.voice {
5121                existing.voice = meta.voice;
5122                changed = true;
5123            }
5124            if existing.meta_custom != meta.custom {
5125                existing.meta_custom = meta.custom;
5126                changed = true;
5127            }
5128            if existing.meta_extra != meta.extra {
5129                existing.meta_extra = meta.extra;
5130                changed = true;
5131            }
5132            // The owner's edition authoritatively declares visibility. A channel the
5133            // owner marks PUBLIC must derive from the root (key = None) — this heals a
5134            // bundle-time misclassification where an attacker set a public channel's
5135            // grant key to their own, silently addressing it at a plane only they read.
5136            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
5137            // record stays public) until the convert flow (key mint + cursor rebase
5138            // to the conversion's channel epoch) lands — the send side refuses to
5139            // publish one, and a foreign client's conversion won't move us.
5140            if !meta.private && (existing.private || existing.key.is_some()) {
5141                existing.private = false;
5142                existing.key = None;
5143                changed = true;
5144            }
5145            changed
5146        }
5147        None if !meta.private => {
5148            // A public channel derives its Chat Plane from the community_root at the
5149            // current root epoch (key = None); its stored epoch mirrors the root.
5150            out.channels.push(ChannelV2 {
5151                id,
5152                name: meta.name,
5153                private: false,
5154                key: None,
5155                epoch: out.root_epoch,
5156                voice: meta.voice,
5157                meta_custom: meta.custom,
5158                meta_extra: meta.extra,
5159            });
5160            true
5161        }
5162        None => {
5163            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
5164            // generation — CORD-03 §2 numbers the first private key epoch 1). The
5165            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
5166            // delivers a key, every read/send/subscribe path skips the channel; the
5167            // root-fallback in `channel_secret` is never taken for it.
5168            out.channels.push(ChannelV2 {
5169                id,
5170                name: meta.name,
5171                private: true,
5172                key: None,
5173                epoch: Epoch(0),
5174                voice: meta.voice,
5175                meta_custom: meta.custom,
5176                meta_extra: meta.extra,
5177            });
5178            true
5179        }
5180    }
5181}
5182
5183// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
5184
5185/// The outcome of a rekey-follow pass.
5186pub struct RekeyFollow {
5187    /// The community after adopting every rotation it could catch up on, or `None`
5188    /// if nothing advanced.
5189    pub updated: Option<CommunityV2>,
5190    /// A base rotation removed us — the caller tears the local hold down (the
5191    /// updated community is not persisted in that case).
5192    pub self_removed: bool,
5193    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
5194    /// flag is already set; the caller surfaces the death and stops following.
5195    pub dissolved: bool,
5196}
5197
5198/// The most archived base roots a channel-rekey lookup fans across per step. A
5199/// standalone rekey rides the minter's then-current root and a removal's rides the
5200/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
5201/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
5202/// an older root than the fan reaches) only heals via a fresh invite bundle — the
5203/// walk is strictly sequential, so a later rotation can't be reached either.
5204const MAX_ADDRESSING_ROOTS: usize = 8;
5205
5206/// The base roots a channel rekey may be addressed under, freshest first: the
5207/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
5208/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
5209/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
5210/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
5211/// fetch addresses but auth never registered is invisible on an AUTH-gating
5212/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
5213/// wedges at its old epoch while the base advances.
5214pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5215    let mut roots: Vec<[u8; 32]> = vec![cur_root];
5216    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5217        .unwrap_or_default();
5218    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5219    for (_, r) in archived {
5220        if !roots.contains(&r) {
5221            roots.push(r);
5222        }
5223    }
5224    roots.truncate(MAX_ADDRESSING_ROOTS);
5225    roots
5226}
5227
5228/// Follow rekeys for a held community: advance the base (root) epoch and each
5229/// Private channel's epoch as far as authorized rotations allow, adopting the
5230/// fresh key we're still a recipient of at each step and dropping a scope we've
5231/// been removed from. Persists the result. Called when a rekey wrap arrives in
5232/// realtime so a long-running bot keeps decrypting after a rotation instead of
5233/// going silent.
5234///
5235/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
5236/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
5237/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
5238/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
5239/// roster (folded + persisted by `follow_control`), minus the banlist — so an
5240/// admin-created private channel keys up on every member.
5241///
5242/// **Addressing fans across held base roots:** each channel step queries its
5243/// next-epoch rekey address under the current root AND the archived prior roots,
5244/// so a base adopt landing before a Refounding's prior-root-addressed channel
5245/// rekeys (or before a creation delivery minted under an older root) can't
5246/// strand the channel.
5247///
5248/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
5249/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
5250/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
5251/// every follower converges. An incomplete rotation (a missing chunk) never
5252/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
5253/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
5254/// §2: "a convergence check, not a secrecy mechanism") — authority is its
5255/// boundary; its epoch is the scan cursor, advancing past complete rotations
5256/// that exclude us so the walk converges on the channel's current epoch.
5257/// Diagnostic: run the base-rotation fetch+parse pipeline for a wedged community
5258/// and report, per rotation found at the next-epoch base plane, WHY
5259/// `follow_rekeys` did or didn't adopt it — the exact `advance_scope` gate that
5260/// tripped. Read-only. Every rotator/owner is a PUBLIC key; no secret material
5261/// is returned.
5262#[cfg(debug_assertions)]
5263pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5264    transport: &T,
5265    community: &CommunityV2,
5266) -> Result<serde_json::Value, String> {
5267    let my_xonly = me_pk()?.to_bytes();
5268    let owner = community.owner()?;
5269    let owner_hex = owner.to_hex();
5270    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5271    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5272    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5273    let held_epoch = community.root_epoch;
5274    let held_key = community.community_root;
5275    let next = Epoch(held_epoch.0.saturating_add(1));
5276    let group = base_rekey_group_key(&held_key, community.id(), next);
5277    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5278    let rotations = rekey::collect_rotations(&chunks);
5279
5280    let reports: Vec<serde_json::Value> = rotations
5281        .iter()
5282        .map(|r| {
5283            let rotator_is_owner = r.rotator == owner;
5284            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
5285            // Roster, not owner-identity — report that gate, not just owner-equality.
5286            let rotator_authorized = rotator_is_owner
5287                || (!banned.contains(&r.rotator.to_hex())
5288                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5289            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5290            let epoch_ok = r.new_epoch.0 == next.0;
5291            let complete = r.is_complete();
5292            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5293            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5294            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
5295            // is a takeover attempt — this tells whether an "owner must be kept"
5296            // adopt-block would be safe here (it would falsely reject a legitimate
5297            // rotation that happened to exclude the owner).
5298            let owner_kept = r.rotator == owner
5299                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5300            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
5301            let verdict = if !rotator_authorized {
5302                "REJECTED: rotator holds no BAN authority in the folded roster"
5303            } else if !scope_ok {
5304                "REJECTED: scope is not Root"
5305            } else if !epoch_ok {
5306                "REJECTED: new_epoch != held+1"
5307            } else if !complete {
5308                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5309            } else if continuity != "Extends" {
5310                "REJECTED: continuity does not extend my held root (FORK/GAP)"
5311            } else if has_my_blob {
5312                "ADOPT: authorized + complete + continuous + my blob present"
5313            } else {
5314                "REMOVED: complete authorized rotation with no blob for me"
5315            };
5316            serde_json::json!({
5317                "rotator": r.rotator.to_hex(),
5318                "rotator_is_recorded_owner": rotator_is_owner,
5319                "rotator_authorized_ban": rotator_authorized,
5320                "scope_is_root": scope_ok,
5321                "new_epoch": r.new_epoch.0,
5322                "prev_epoch": r.prev_epoch.0,
5323                "declared_chunks": r.declared_chunks,
5324                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5325                "is_complete": complete,
5326                "continuity_vs_held_root": continuity,
5327                "my_blob_present": has_my_blob,
5328                "owner_kept": owner_kept,
5329                "blob_count": r.blobs.len(),
5330                "verdict": verdict,
5331            })
5332        })
5333        .collect();
5334
5335    Ok(serde_json::json!({
5336        "recorded_owner": owner.to_hex(),
5337        "held_root_epoch": held_epoch.0,
5338        "probing_next_epoch": next.0,
5339        "base_plane_pk": group.pk_hex(),
5340        "raw_chunks_parsed": chunks.len(),
5341        "rotations_found": rotations.len(),
5342        "rotations": reports,
5343    }))
5344}
5345
5346pub async fn follow_rekeys<T: Transport + ?Sized>(
5347    transport: &T,
5348    community: &CommunityV2,
5349    session: &SessionGuard,
5350) -> Result<RekeyFollow, String> {
5351    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
5352    // past its tombstone — don't adopt a rotation into a grave.
5353    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5354    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5355        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5356    }
5357    // An offline member must also LEARN of a death: the tombstone rides its own
5358    // public plane, which the live sub watches but no catch-up fetch touched —
5359    // without this, a member who slept through a dissolution follows (and posts
5360    // into) a grave forever. Fail-open on transport failure: availability is
5361    // never death.
5362    if is_dissolved(transport, community).await {
5363        if session.is_valid() {
5364            let _ = crate::db::community::set_community_dissolved(&cid_hex);
5365        }
5366        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5367    }
5368    let signer = crate::signer::active_signer()?;
5369    let my_pk = me_pk()?;
5370    let my_xonly = my_pk.to_bytes();
5371    let owner = community.owner()?;
5372    let owner_hex = owner.to_hex();
5373    let mut cur = community.clone();
5374    let mut changed = false;
5375
5376    // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
5377    // follow_control; the worker folds control right after this rekey pass). This
5378    // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
5379    // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
5380    // the base-admissibility protected-set: a superior whose grant this receiver
5381    // has not yet folded is not in `roster.grants`, so a non-owner Refounding
5382    // excluding them can be adopted within that propagation window. Bounded — the
5383    // owner is ALWAYS hard-protected below (independent of the roster) and can
5384    // counter-refound; and it is inherent to eventual consistency (one cannot gate
5385    // on a grant never seen). Tightening this (fold control before the first rekey,
5386    // or gate non-owner adoption on roster freshness) is a follow-on.
5387    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5388    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5389    let me_hex = my_pk.to_hex();
5390    // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
5391    // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
5392    // never honored by a lagging client." Persisted heads ARE the right floor
5393    // here (unlike the roster fold, which must resolve in-pass): a rotation is
5394    // judged against a roster we already folded, and `follow_control` — v2's only
5395    // roster writer — persists the heads in the same pass it writes the roster.
5396    // A joiner who sees a rotation before folding control simply parks it and
5397    // heals on the next follow, which runs control first.
5398    let cited_ok = |rot: &rekey::Rotation| -> bool {
5399        citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5400    };
5401    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5402        if *rotator == owner {
5403            return true;
5404        }
5405        let rh = rotator.to_hex();
5406        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5407    };
5408    // Concluding MY removal takes more than the bit: the rotator must strictly
5409    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
5410    // removed target"), so an equal-rank admin can never silently evict a peer
5411    // (or the owner) by minting a complete rotation that skips their blob.
5412    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5413        if *rotator == owner {
5414            return true;
5415        }
5416        let rh = rotator.to_hex();
5417        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5418    };
5419    // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
5420    // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
5421    // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
5422    // owner is always authorized. (Owner-only here silently wedged every member
5423    // whose community was refounded by a non-owner admin.)
5424    let base_rotator_ok = |rotator: &PublicKey| -> bool {
5425        if *rotator == owner {
5426            return true;
5427        }
5428        let rh = rotator.to_hex();
5429        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5430    };
5431    // Concluding MY removal via a base rotation takes more than the bit: the
5432    // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
5433    // Rotator must strictly outrank every removed target"), so an equal-rank
5434    // admin can never evict a peer (or the owner) by minting a rotation that
5435    // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
5436    let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5437        if *rotator == owner {
5438            return true;
5439        }
5440        let rh = rotator.to_hex();
5441        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5442    };
5443
5444    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
5445    // finite chain terminates naturally; the cap defends against a relay feeding a
5446    // pathological set.
5447    const MAX_STEPS: usize = 128;
5448    for _ in 0..MAX_STEPS {
5449        let mut advanced = false;
5450
5451        // The roots a channel rekey may be addressed under (re-read each pass —
5452        // a base adopt below changes the head, and its predecessor is already
5453        // archived). Shared with streamauth so the auth registration covers
5454        // exactly this fan.
5455        let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5456
5457        // Private channels first: a removal-forced channel rekey rides the PRIOR
5458        // root (CORD-06 D2), so read channels before a base adopt moves it.
5459        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5460        for cid in channel_ids {
5461            let (held_key, held_epoch) = match cur.channel(&cid) {
5462                Some(ch) => (ch.key, ch.epoch),
5463                None => continue,
5464            };
5465            let next = Epoch(held_epoch.0.saturating_add(1));
5466            let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5467            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5468            // root #0 = current, #1.. = archived priors (indices only — root
5469            // bytes are key material and must never reach a log).
5470            for (ri, root) in addressing_roots.iter().enumerate() {
5471                let group = channel_rekey_group_key(root, &cid, next);
5472                let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5473                    Ok(c) => c,
5474                    Err(e) => {
5475                        crate::log_warn!(
5476                            "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5477                            &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5478                        );
5479                        return Err(e);
5480                    }
5481                };
5482                if chunks.is_empty() {
5483                    continue;
5484                }
5485                crate::log_debug!(
5486                    "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5487                    &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5488                );
5489                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5490            }
5491            // Keyless-adopt residual (documented, deferred hardening): a malicious
5492            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
5493            // rotation nothing extends (keyed members' continuity filters it out).
5494            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
5495            // exclude the member outright anyway, so the marginal harm is the wedge
5496            // outliving their demotion.
5497            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5498                Advance::Adopt { new_key } => {
5499                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5500                        ch.key = Some(new_key);
5501                        ch.epoch = next;
5502                    }
5503                    crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5504                    // The adopter's own multi-epoch archive (the minter archived at
5505                    // mint) — this channel's history stays readable across rotations.
5506                    // fetch_channel compensates for the CURRENT epoch, so a failed
5507                    // archive only bites after the NEXT rotation — surface it.
5508                    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) {
5509                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5510                    }
5511                    advanced = true;
5512                    changed = true;
5513                }
5514                Advance::Removed => {
5515                    match held_key {
5516                        // A complete rotation dropped my blob — cut from the channel.
5517                        Some(_) => {
5518                            cur.channels.retain(|c| c.id.0 != cid.0);
5519                        }
5520                        // Keyless scan: this epoch's rotation completed without me.
5521                        // Advance the cursor so the walk converges on the channel's
5522                        // CURRENT epoch — my entry point is its next rotation (whose
5523                        // recipients are the members at that time) or a fresh bundle.
5524                        None => {
5525                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5526                                ch.epoch = next;
5527                            }
5528                        }
5529                    }
5530                    advanced = true;
5531                    changed = true;
5532                }
5533                Advance::Stay => {}
5534            }
5535        }
5536
5537        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
5538        // every public channel, the guestbook, and the control plane by derivation
5539        // (refresh_subscription recomputes the author-set from the new root).
5540        {
5541            let held_epoch = cur.root_epoch;
5542            let held_key = cur.community_root;
5543            let next = Epoch(held_epoch.0.saturating_add(1));
5544            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5545            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5546            let batches = vec![(chunks, Some((held_epoch, held_key)))];
5547            // A non-owner Refounding may only remove members the rotator strictly
5548            // OUTRANKS. The protected set is the owner plus every grant-holder the
5549            // rotator can't act on with BAN (a peer or superior) — excluding one is
5550            // an authority-escalation takeover, so its rotation is inadmissible.
5551            // Plain members hold no grant and are always outranked by a BAN-holder,
5552            // so removing them is legitimate and needs no memberlist.
5553            let base_admissible = |r: &rekey::Rotation| -> bool {
5554                if r.rotator == owner {
5555                    return true; // the owner is supreme.
5556                }
5557                // Uncited (or citing a Grant we haven't synced) → skip entirely:
5558                // neither adopt nor conclude a removal, exactly like an
5559                // unauthorized rotation. It parks and heals on the next follow.
5560                if !cited_ok(r) {
5561                    return false;
5562                }
5563                let rotator_hex = r.rotator.to_hex();
5564                let has_blob = |xonly: &[u8; 32]| {
5565                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5566                };
5567                // The owner is never a valid removed target.
5568                if !has_blob(&owner.to_bytes()) {
5569                    return false;
5570                }
5571                for g in &roster.grants {
5572                    if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5573                        continue; // self, owner (checked), or an already-authorized removal.
5574                    }
5575                    // A grant-holder the rotator can't act on is a peer/superior.
5576                    if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5577                        if let Ok(pk) = PublicKey::from_hex(&g.member) {
5578                            if !has_blob(&pk.to_bytes()) {
5579                                return false; // a peer/superior was excluded.
5580                            }
5581                        }
5582                    }
5583                }
5584                true
5585            };
5586            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5587                Advance::Adopt { new_key } => {
5588                    cur.community_root = new_key;
5589                    cur.root_epoch = next;
5590                    // Archive on adopt: without this, a member who lived through TWO
5591                    // Refoundings loses the middle epoch's public history (only the
5592                    // minter archived it).
5593                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5594                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5595                    }
5596                    advanced = true;
5597                    changed = true;
5598                }
5599                Advance::Removed => {
5600                    if !session.is_valid() {
5601                        return Err("account changed during rekey follow".to_string());
5602                    }
5603                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5604                }
5605                Advance::Stay => {}
5606            }
5607        }
5608
5609        if !advanced {
5610            break;
5611        }
5612    }
5613
5614    if !changed {
5615        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5616    }
5617    if !session.is_valid() {
5618        return Err("account changed during rekey follow".to_string());
5619    }
5620    // A leave/delete raced this follow: saving would resurrect the community row
5621    // (the save is an upsert) with no floor rows behind it.
5622    if crate::db::community::community_protocol(community.id())?.is_none() {
5623        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5624    }
5625    crate::db::community::save_community_v2(&cur)?;
5626    // Carry my own live links across the rotation someone ELSE performed
5627    // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
5628    // their own — so without this every other creator's links keep vending the
5629    // superseded root and drop new joiners onto a dead epoch, which is exactly
5630    // the stranding the stable-URL refresh exists to prevent. Best-effort and
5631    // idempotent: a creator with no links for this community returns early, and
5632    // a failure only delays the heal until the next adoption or refound.
5633    let _ = refresh_public_links(transport, &cur).await;
5634    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5635}
5636
5637/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
5638/// address.
5639enum Advance {
5640    /// Adopt this fresh key for `next_epoch`.
5641    Adopt { new_key: [u8; 32] },
5642    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
5643    Removed,
5644    /// No owner rotation extends my held epoch (yet) — keep the current key.
5645    Stay,
5646}
5647
5648/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
5649async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5650    transport: &T,
5651    relays: &[String],
5652    group: &GroupKey,
5653) -> Result<Vec<rekey::RekeyChunk>, String> {
5654    // A rekey plane address is community_root-derived, so ANY member can seal junk
5655    // 3303s there — a flood (or, organically, a large community's own multi-chunk
5656    // rotation past the newest window) could bury the genuine owner/admin rotation
5657    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
5658    // control pager's discipline) so a buried authorized chunk is still recovered;
5659    // the seal + authority filter downstream drops the junk. Bounded — a sustained
5660    // flood past this depth degrades to "adopt one pass late", never a false state.
5661    const REKEY_PAGE: usize = 200;
5662    const REKEY_MAX_PAGES: usize = 6;
5663    let mut out = Vec::new();
5664    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5665    let mut until: Option<u64> = None;
5666    let mut oldest: Option<u64> = None;
5667    for _ in 0..REKEY_MAX_PAGES {
5668        let query = Query {
5669            kinds: vec![stream::KIND_WRAP],
5670            authors: vec![group.pk_hex()],
5671            until,
5672            limit: Some(REKEY_PAGE),
5673            ..Default::default()
5674        };
5675        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
5676        // shared user-authed client's REQ for a plane's events is CLOSED, so an
5677        // offline rotation catch-up would return nothing and wedge at the old
5678        // epoch. `fetch_plane` rides a connection authed as the plane itself.
5679        let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5680        let mut fresh = 0usize;
5681        for w in &wraps {
5682            if !seen.insert(w.id) {
5683                continue;
5684            }
5685            fresh += 1;
5686            let at = w.created_at.as_secs();
5687            if oldest.is_none_or(|o| at < o) {
5688                oldest = Some(at);
5689            }
5690            if let Ok(opened) = stream::open_wrap(w, group) {
5691                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5692                    out.push(chunk);
5693                }
5694            }
5695        }
5696        // Drained, or a same-second wall the pager can't step past (second-granular
5697        // until) — either way stop; the accumulated set is what advance_scope folds.
5698        if fresh == 0 || wraps.len() < REKEY_PAGE {
5699            break;
5700        }
5701        match oldest {
5702            Some(o) if o > 0 => until = Some(o),
5703            _ => break,
5704        }
5705    }
5706    Ok(out)
5707}
5708
5709/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
5710/// batch pairs the chunks fetched under one root with the continuity to demand of
5711/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
5712/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
5713/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
5714/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
5715/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
5716/// seal authority is the boundary). Among qualifying rotations carrying my blob
5717/// the lexicographically lowest new key wins (convergent). All complete
5718/// candidates without my blob conclude Removed for a KEYED holder only when one
5719/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
5720/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
5721/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
5722async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5723    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5724    scope: RekeyScope,
5725    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5726    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5727    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5728    signer: &S,
5729    my_xonly: &[u8; 32],
5730    next_epoch: Epoch,
5731) -> Advance {
5732    let mut winners: Vec<[u8; 32]> = Vec::new();
5733    let mut saw_complete_candidate = false;
5734    let mut saw_outranking_candidate = false;
5735    let keyed = batches.iter().any(|(_, held)| held.is_some());
5736    for (chunks, held) in batches {
5737        let rotations = rekey::collect_rotations(chunks);
5738        for r in &rotations {
5739            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
5740                continue;
5741            }
5742            if let Some((held_epoch, held_key)) = held {
5743                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
5744                    continue;
5745                }
5746            }
5747            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
5748            // target. An authorized-but-inadmissible rotation (one that excludes
5749            // the owner or a peer/superior the rotator can't act on) is a takeover
5750            // attempt — skip it entirely, so it neither adopts nor concludes a
5751            // removal (it forks; the honest chain wins).
5752            if !admissible(r) {
5753                continue;
5754            }
5755            saw_complete_candidate = true;
5756            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
5757            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
5758                if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
5759                    winners.push(k);
5760                }
5761            }
5762        }
5763    }
5764    if !winners.is_empty() {
5765        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
5766        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
5767        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
5768        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
5769        // the same epoch (separate rotations): every follower converges on the same
5770        // lowest new key. A wrap served under two addressing roots can't double-count:
5771        // each rekey wrap opens under exactly one root's group key.
5772        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
5773        return Advance::Adopt { new_key: winners[idx] };
5774    }
5775    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
5776        Advance::Removed
5777    } else {
5778        Advance::Stay
5779    }
5780}
5781
5782#[cfg(test)]
5783mod tests {
5784    use crate::ClientRelayExt;
5785    use nostr_sdk::prelude::FinalizeEvent;
5786    use super::super::super::transport::memory::MemoryRelay;
5787    use super::*;
5788    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
5789
5790    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
5791    fn account_name(n: u32) -> String {
5792        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5793        let mut acct = String::from("npub1");
5794        let mut v = n as usize;
5795        for _ in 0..58 {
5796            acct.push(B[v % 32] as char);
5797            v = v / 32 + 7;
5798        }
5799        acct
5800    }
5801
5802    /// One test participant: its identity keys and its isolated account DB dir.
5803    struct Actor {
5804        keys: Keys,
5805        account: String,
5806    }
5807
5808    /// Two participants sharing one relay but isolated per-account DBs — the
5809    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
5810    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
5811    /// the per-account id caches, so account A's community is invisible to B
5812    /// until B legitimately joins.
5813    struct TestBed {
5814        _tmp: tempfile::TempDir,
5815        _guard: std::sync::MutexGuard<'static, ()>,
5816        relay: MemoryRelay,
5817        relays: Vec<String>,
5818    }
5819
5820    impl TestBed {
5821        fn new() -> (TestBed, Actor, Actor) {
5822            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
5823            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5824            crate::db::close_database();
5825            crate::db::clear_id_caches();
5826            let tmp = tempfile::tempdir().unwrap();
5827            crate::db::set_app_data_dir(tmp.path().to_path_buf());
5828
5829            let mk = || {
5830                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5831                let account = account_name(n);
5832                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
5833                crate::db::set_current_account(account.clone()).unwrap();
5834                crate::db::init_database(&account).unwrap();
5835                Actor { keys: Keys::generate(), account }
5836            };
5837            let owner = mk();
5838            let member = mk();
5839            let _ = crate::state::take_nostr_client();
5840            let bed = TestBed {
5841                _tmp: tmp,
5842                _guard: guard,
5843                relay: MemoryRelay::new(),
5844                relays: vec!["wss://r".to_string()],
5845            };
5846            (bed, owner, member)
5847        }
5848
5849        /// Become `actor`: swap the account DB + identity, as a real session swap.
5850        /// Bumps the session generation like production `swap_session` does — so any task a
5851        /// prior actor spawned (e.g. the migration finalize) dies at its SessionGuard check
5852        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
5853        fn swap_to(&self, actor: &Actor) {
5854            crate::state::bump_session_generation();
5855            crate::db::set_current_account(actor.account.clone()).unwrap();
5856            crate::db::init_database(&actor.account).unwrap();
5857            crate::db::clear_id_caches();
5858            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
5859            crate::state::set_my_public_key(actor.keys.public_key());
5860        }
5861    }
5862
5863    /// Legacy single-actor helper (the create/send tests below).
5864    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
5865        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5866        crate::db::close_database();
5867        crate::db::clear_id_caches();
5868        let tmp = tempfile::tempdir().unwrap();
5869        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
5870        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5871        let acct = account_name(n);
5872        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5873        crate::db::set_app_data_dir(tmp.path().to_path_buf());
5874        crate::db::set_current_account(acct.clone()).unwrap();
5875        crate::db::init_database(&acct).unwrap();
5876        let _ = crate::state::take_nostr_client();
5877        let owner = Keys::generate();
5878        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
5879        crate::state::set_my_public_key(owner.public_key());
5880        (tmp, guard, owner)
5881    }
5882
5883    /// A transport that simulates a session swap landing DURING a fetch await —
5884    /// so a join straddling the fetch sees an invalid session and aborts.
5885    struct SwapMidFetch {
5886        inner: MemoryRelay,
5887    }
5888    #[async_trait::async_trait]
5889    impl Transport for SwapMidFetch {
5890        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5891        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5892            self.inner.publish(e, r).await
5893        }
5894        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5895            self.inner.publish_durable(e, r).await
5896        }
5897        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5898            let out = self.inner.fetch(q, r).await;
5899            crate::state::bump_session_generation();
5900            out
5901        }
5902    }
5903
5904    /// Bumps the session generation on the first `publish_durable` — the rekey
5905    /// crate a private-channel create ships before it writes anything locally.
5906    struct SwapMidPublish {
5907        inner: MemoryRelay,
5908    }
5909    #[async_trait::async_trait]
5910    impl Transport for SwapMidPublish {
5911        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5912        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
5913            self.inner.publish(e, r).await
5914        }
5915        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5916            let out = self.inner.publish_durable(e, r).await;
5917            crate::state::bump_session_generation();
5918            out
5919        }
5920        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5921            self.inner.fetch(q, r).await
5922        }
5923    }
5924
5925    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
5926    /// the production `LiveTransport` union (first-responding relay's batch, no
5927    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
5928    /// the only harness that can exercise the revocation-race ordering.
5929    struct FixedFetch {
5930        events: Vec<Event>,
5931    }
5932    #[async_trait::async_trait]
5933    impl Transport for FixedFetch {
5934        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5935        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5936            Ok(())
5937        }
5938        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
5939            Ok(())
5940        }
5941        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
5942            Ok(self.events.clone())
5943        }
5944    }
5945
5946    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
5947    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
5948    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
5949        let q = Query {
5950            kinds: vec![stream::KIND_WRAP],
5951            p_tags: vec![me.to_hex()],
5952            k_tags: vec!["3313".to_string()],
5953            ..Default::default()
5954        };
5955        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
5956    }
5957
5958    #[tokio::test]
5959    async fn create_persists_and_reloads_a_v2_community() {
5960        let (_tmp, _guard, owner) = init_test_db();
5961        let relay = MemoryRelay::new();
5962        let relays = vec!["wss://r".to_string()];
5963
5964        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
5965        assert!(created.identity.verify());
5966        assert_eq!(created.owner().unwrap(), owner.public_key());
5967        assert_eq!(created.channels.len(), 1);
5968
5969        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
5970        assert_eq!(
5971            crate::db::community::community_protocol(created.id()).unwrap(),
5972            Some(crate::community::ConcordProtocol::V2)
5973        );
5974        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
5975        assert_eq!(loaded.name, "Vectorville");
5976        assert_eq!(loaded.community_root, created.community_root);
5977        assert_eq!(loaded.identity, created.identity);
5978        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
5979        assert!(!loaded.channels[0].private);
5980
5981        // The genesis control editions + the owner Join landed on the relay.
5982        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
5983    }
5984
5985    #[tokio::test]
5986    async fn owner_sends_and_reads_back_a_message() {
5987        let (_tmp, _guard, _owner) = init_test_db();
5988        let relay = MemoryRelay::new();
5989        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
5990        let general = community.channels[0].id;
5991
5992        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
5993        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
5994        assert_ne!(id1, id2);
5995
5996        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
5997        let texts: Vec<String> = page
5998            .iter()
5999            .filter_map(|f| match &f.event {
6000                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6001                _ => None,
6002            })
6003            .collect();
6004        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6005    }
6006
6007    #[tokio::test]
6008    async fn a_second_member_reads_the_public_channel_from_the_root() {
6009        // A member who holds the community_root (via an invite bundle, modeled
6010        // here by cloning the community) reads the owner's public-channel message
6011        // — public channels need no key delivery, they derive from the root.
6012        let (_tmp, _guard, _owner) = init_test_db();
6013        let relay = MemoryRelay::new();
6014        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6015        let general = community.channels[0].id;
6016        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6017
6018        // The "member" reconstructs the same read coordinates from the root.
6019        let member_view = community.clone();
6020        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6021        assert_eq!(page.len(), 1);
6022        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6023        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6024    }
6025
6026    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
6027
6028    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6029        fetch_channel(relay, community, channel, 100)
6030            .await
6031            .unwrap()
6032            .iter()
6033            .filter_map(|f| match &f.event {
6034                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6035                _ => None,
6036            })
6037            .collect()
6038    }
6039
6040    #[tokio::test]
6041    async fn direct_invite_full_loop_owner_and_member_converse() {
6042        let (bed, owner, member) = TestBed::new();
6043
6044        // Owner creates a community, posts, and Direct-Invites the member's npub.
6045        bed.swap_to(&owner);
6046        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6047        let general = community.channels[0].id;
6048        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6049        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6050
6051        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
6052        bed.swap_to(&member);
6053        assert!(
6054            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6055            "the member does not hold the community before joining"
6056        );
6057        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6058        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6059        assert_eq!(joined.id().0, community.id().0, "joined the same community");
6060        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6061        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6062
6063        // The member reads the owner's public-channel history and replies.
6064        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6065        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6066
6067        // The owner reads the member's reply.
6068        bed.swap_to(&owner);
6069        assert_eq!(
6070            texts_in(&bed.relay, &community, &general).await,
6071            vec!["owner: welcome!", "member: thanks for the invite"],
6072            "both actors' messages interleave in ms order on the shared channel"
6073        );
6074
6075        // The Guestbook memberlist now folds both participants.
6076        let members = memberlist(&bed.relay, &community).await.unwrap();
6077        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6078        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6079        assert_eq!(members.len(), 2);
6080    }
6081
6082    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
6083    /// refuses to join — no Guestbook Join publish, no local write — through the shared
6084    /// accept path every door (direct invite, parked, public link, migration) funnels into.
6085    #[tokio::test]
6086    async fn a_banned_member_is_refused_at_join_time() {
6087        let (bed, owner, member) = TestBed::new();
6088
6089        bed.swap_to(&owner);
6090        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6091        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6092        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6093
6094        bed.swap_to(&member);
6095        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6096        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6097        assert!(err.contains("banned"), "refusal names the reason: {err}");
6098        assert!(
6099            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6100            "a refused join persists nothing"
6101        );
6102
6103        // The gate is the LAST word only for banned members: an unbanned bystander with
6104        // the same invite path still joins (the gate doesn't over-refuse).
6105        bed.swap_to(&owner);
6106        set_banlist(&bed.relay, &community, &[]).await.unwrap();
6107        bed.swap_to(&member);
6108        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6109        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6110    }
6111
6112    /// End-to-end member migration: a member holding a v1 community folds the owner's
6113    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
6114    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
6115    #[tokio::test]
6116    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6117        use crate::community::migration;
6118        let (bed, owner, member) = TestBed::new();
6119
6120        // Owner builds the v2 twin (real, verifiable on the shared relay).
6121        bed.swap_to(&owner);
6122        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6123        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6124        let jm = join_material(&v2);
6125
6126        // The member holds a v1 community owned by the SAME owner identity (the migration
6127        // premise) — construct + save it, and hold its server root.
6128        bed.swap_to(&member);
6129        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6130        let v1_cid = v1.id.to_hex();
6131        v1.owner_attestation = Some({
6132            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6133                .finalize(&owner.keys).unwrap().as_json()
6134        });
6135        crate::db::community::save_community(&v1).unwrap();
6136        let v1_channel = v1.channels[0].id.to_hex();
6137
6138        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
6139        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6140        let signpost = migration::MigrationSignpost {
6141            v2_community_id: v2_hex.clone(),
6142            owner_xonly: owner.keys.public_key().to_hex(),
6143            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6144            relays: bed.relays.clone(),
6145            name: "Guild".into(),
6146            primary_channel: v1_channel.clone(),
6147            root_epoch: 0,
6148        };
6149        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6150        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6151
6152        // Drive the migration: opens m, joins v2 (ban-gated), flips.
6153        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6154        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6155
6156        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
6157        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6158        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6159        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6160        let _ = v1_channel;
6161
6162        // Idempotent: a second drive is a no-op (already flipped).
6163        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6164    }
6165
6166    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
6167    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
6168    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
6169    /// couldn't (that twin had mismatched ids).
6170    #[tokio::test]
6171    async fn owner_wizard_then_member_migrate_and_stitch() {
6172        use crate::community::migration;
6173        let (bed, owner, member) = TestBed::new();
6174        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6175
6176        // Owner holds a v1 community (they created it) with one channel.
6177        bed.swap_to(&owner);
6178        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6179        let v1_cid = v1.id.to_hex();
6180        let v1_channel = v1.channels[0].id.to_hex();
6181        v1.owner_attestation = Some({
6182            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6183                .finalize(&owner.keys).unwrap().as_json()
6184        });
6185        crate::db::community::save_community(&v1).unwrap();
6186
6187        // Run the wizard.
6188        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6189        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6190            "owner's own client flipped to v2");
6191        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
6192        // primary channel REUSES the v1 channel id.
6193        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6194            "owner channel stitched to v2");
6195
6196        // A MEMBER holding the same v1 community folds the carrier and migrates.
6197        bed.swap_to(&member);
6198        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6199        // The member's v1 community must be the SAME id + root the owner published under.
6200        m_v1.id = v1.id;
6201        m_v1.server_root_key = v1.server_root_key.clone();
6202        m_v1.channels[0].id = v1.channels[0].id;
6203        m_v1.owner_attestation = v1.owner_attestation.clone();
6204        crate::db::community::save_community(&m_v1).unwrap();
6205
6206        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
6207        // AND auto-drives the flip — the live one-event member experience, no manual step.
6208        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6209        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6210        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6211            "the FOLD ITSELF flipped the member (auto-drive)");
6212        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6213            "member holds the v2 twin");
6214        // A manual re-drive is an idempotent no-op.
6215        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6216    }
6217
6218    /// The wizard records the twin in the cross-device community list, like every other v2
6219    /// join/create path. Sibling devices normally discover the twin by folding the carrier
6220    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
6221    /// the list is its only route in.
6222    #[tokio::test]
6223    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6224        use crate::community::migration;
6225        let (bed, owner, _member) = TestBed::new();
6226        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6227
6228        bed.swap_to(&owner);
6229        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6230        let v1_cid = v1.id.to_hex();
6231        v1.owner_attestation = Some({
6232            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6233                .finalize(&owner.keys).unwrap().as_json()
6234        });
6235        crate::db::community::save_community(&v1).unwrap();
6236
6237        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6238
6239        // The twin is live in the published list, so a fresh/carrier-less device finds it.
6240        let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6241            .expect("the wizard published a community list");
6242        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6243        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
6244        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
6245        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
6246        assert!(
6247            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6248            "migration must not tombstone the v1 community"
6249        );
6250    }
6251
6252    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
6253    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
6254    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
6255    /// (the double-mint orphan) and race its flip against the first.
6256    #[tokio::test]
6257    async fn wizard_refuses_while_a_drive_holds_the_claim() {
6258        use crate::community::migration;
6259        let (bed, owner, _member) = TestBed::new();
6260        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6261
6262        bed.swap_to(&owner);
6263        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6264        let v1_cid = v1.id.to_hex();
6265        v1.owner_attestation = Some({
6266            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6267                .finalize(&owner.keys).unwrap().as_json()
6268        });
6269        crate::db::community::save_community(&v1).unwrap();
6270
6271        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
6272        migration::test_hold_drive_claim(&v1_cid);
6273        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6274        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
6275        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
6276        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
6277        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
6278
6279        // Once the drive releases, the wizard runs normally.
6280        migration::test_release_drive_claim(&v1_cid);
6281        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6282        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6283    }
6284
6285    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
6286    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
6287    /// channel-less struct — pruning exactly the rows the flip just re-parented).
6288    /// Proves the lock actually serializes rather than being a no-op: with the lock held
6289    /// the wizard cannot reach its flip, and it completes once released.
6290    #[tokio::test]
6291    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
6292        use crate::community::migration;
6293        let (bed, owner, _member) = TestBed::new();
6294        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6295        // Shared across the spawned wizard, so both halves see the same relay state.
6296        let relay = std::sync::Arc::new(MemoryRelay::new());
6297
6298        bed.swap_to(&owner);
6299        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6300        let v1_cid = v1.id.to_hex();
6301        let v1_channel = v1.channels[0].id.to_hex();
6302        v1.owner_attestation = Some({
6303            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6304                .finalize(&owner.keys).unwrap().as_json()
6305        });
6306        crate::db::community::save_community(&v1).unwrap();
6307
6308        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
6309        // the flip runs — exactly what a follow worker would have loaded.
6310        let twin = create_migration_twin(
6311            &*relay, "Guild", bed.relays.clone(), None,
6312            (v1.channels[0].id, "general".to_string()),
6313        ).await.unwrap();
6314        let v2_id = twin.identity.community_id;
6315        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
6316        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6317
6318        // A follow pass is in flight: it holds the lock across its network stage.
6319        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
6320
6321        let wizard = tokio::spawn({
6322            let relay = relay.clone();
6323            let v1 = v1.clone();
6324            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
6325        });
6326
6327        // The wizard runs its network phases but must BLOCK at the flip.
6328        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
6329        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
6330        assert!(
6331            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
6332            "the fence must not be stamped while the follow lock is held"
6333        );
6334
6335        // The follow pass finishes; the flip proceeds.
6336        drop(held);
6337        let flipped = wizard.await.unwrap().unwrap();
6338        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
6339        assert_eq!(
6340            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
6341            Some(v2_hex.as_str()),
6342            "the channel row is stitched to the twin, not pruned"
6343        );
6344    }
6345
6346    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
6347    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
6348    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
6349    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
6350    #[tokio::test]
6351    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
6352        use crate::community::migration;
6353        let (bed, owner, banned) = TestBed::new();
6354        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6355
6356        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
6357        bed.swap_to(&owner);
6358        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6359        let v1_cid = v1.id.to_hex();
6360        v1.owner_attestation = Some({
6361            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6362                .finalize(&owner.keys).unwrap().as_json()
6363        });
6364        crate::db::community::save_community(&v1).unwrap();
6365        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6366
6367        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6368
6369        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
6370        bed.swap_to(&banned);
6371        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6372        m_v1.id = v1.id;
6373        m_v1.server_root_key = v1.server_root_key.clone();
6374        m_v1.channels[0].id = v1.channels[0].id;
6375        m_v1.owner_attestation = v1.owner_attestation.clone();
6376        crate::db::community::save_community(&m_v1).unwrap();
6377        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6378
6379        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
6380        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
6381        let payload = migration::parse_migration_payload(&raw).unwrap();
6382        assert!(payload.m.is_some());
6383        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
6384        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
6385        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
6386        assert!(
6387            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
6388            "banned member never acquires the v2 twin"
6389        );
6390    }
6391
6392    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
6393    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
6394    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
6395    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
6396    #[tokio::test]
6397    async fn wizard_resume_continues_on_the_same_twin() {
6398        use crate::community::migration;
6399        let (bed, owner, banned) = TestBed::new();
6400        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6401
6402        bed.swap_to(&owner);
6403        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6404        // A second channel + a banned member make the resumed phase-1b tail REAL work.
6405        let mut sibling = v1.channels[0].clone();
6406        sibling.id = crate::community::ChannelId(crate::community::random_32());
6407        sibling.name = "offtopic".into();
6408        v1.channels.push(sibling.clone());
6409        let v1_cid = v1.id.to_hex();
6410        v1.owner_attestation = Some({
6411            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6412                .finalize(&owner.keys).unwrap().as_json()
6413        });
6414        crate::db::community::save_community(&v1).unwrap();
6415        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6416
6417        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
6418        // BEFORE the sibling channel + banlist clone ever ran.
6419        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
6420        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
6421        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6422
6423        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
6424        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6425        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
6426        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6427        assert_eq!(ledger_v2, minted_hex);
6428        assert_eq!(phase, migration::PHASE_FLIPPED);
6429        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
6430        // (folding the twin's control plane yields the banned npub).
6431        assert_eq!(
6432            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
6433            Some(minted_hex.as_str()),
6434            "sibling channel re-parented by the resumed run"
6435        );
6436        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6437        let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
6438            .await
6439            .map(|(c, h, b)| (c, h, b))
6440            .unwrap();
6441        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
6442            "the resumed banlist clone is folded from the twin's wire control plane");
6443
6444        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
6445        // → the re-run reports SUCCESS and heals, never "already been migrated".
6446        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6447        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6448        assert_eq!(healed, minted_hex);
6449        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
6450        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
6451
6452        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
6453        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
6454        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
6455        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
6456        // fresh v1 owned by the same owner.
6457        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
6458        let v1b_cid = v1b.id.to_hex();
6459        v1b.owner_attestation = Some({
6460            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
6461                .finalize(&owner.keys).unwrap().as_json()
6462        });
6463        crate::db::community::save_community(&v1b).unwrap();
6464        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
6465        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
6466        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
6467        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
6468        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
6469        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
6470        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
6471    }
6472
6473    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
6474    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
6475    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
6476    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
6477    #[tokio::test]
6478    async fn birth_refound_seeds_an_explicit_roster() {
6479        let (bed, owner, _m) = TestBed::new();
6480        bed.swap_to(&owner);
6481        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6482            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6483        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
6484        // Two strangers who never join — pure seeded members.
6485        let ghost_a = Keys::generate().public_key();
6486        let ghost_b = Keys::generate().public_key();
6487
6488        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6489        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
6490
6491        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
6492        // ever published a Join.
6493        let members = memberlist(&bed.relay, &rolled).await.unwrap();
6494        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
6495        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
6496
6497        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
6498        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
6499        // a fresh twin's snapshot only counts because the owner minted epoch 1.
6500        let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
6501            .expect("the epoch-1 twin verifies from its compacted control plane");
6502
6503        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
6504        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
6505        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
6506        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
6507        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
6508    }
6509
6510    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
6511    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
6512    /// it before the snapshot, so the refound still completes instead of aborting forever.
6513    #[tokio::test]
6514    async fn birth_refound_ignores_a_banned_seed_entry() {
6515        let (bed, owner, _m) = TestBed::new();
6516        bed.swap_to(&owner);
6517        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6518            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6519        let good = Keys::generate().public_key();
6520        let banned = Keys::generate();
6521        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
6522        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
6523        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
6524
6525        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
6526            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
6527        assert_eq!(rolled.root_epoch, Epoch(1));
6528        let members = memberlist(&bed.relay, &rolled).await.unwrap();
6529        assert!(members.contains(&good), "the non-banned seed lands");
6530        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
6531    }
6532
6533    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
6534    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
6535    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
6536    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
6537    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
6538    #[tokio::test]
6539    async fn a_seeded_member_receives_a_later_refound_rekey() {
6540        let (bed, owner, _m) = TestBed::new();
6541        bed.swap_to(&owner);
6542        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
6543            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
6544        let ghost = Keys::generate();
6545        // Birth refound seeds the ghost (never joins, holds no keys).
6546        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
6547        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
6548
6549        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
6550        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
6551        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
6552        // memberlist proves both: the refound saw them as a member and carried them forward, so
6553        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
6554        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
6555        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
6556        assert!(
6557            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
6558            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
6559        );
6560    }
6561
6562    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
6563    /// MANAGE_ROLES there), while a plain member is not.
6564    #[tokio::test]
6565    async fn v1_admin_stays_admin_across_migration() {
6566        use crate::community::migration;
6567        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6568        let (bed, owner, admin) = TestBed::new();
6569        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6570
6571        bed.swap_to(&owner);
6572        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6573        let v1_cid = v1.id.to_hex();
6574        v1.owner_attestation = Some({
6575            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6576                .finalize(&owner.keys).unwrap().as_json()
6577        });
6578        crate::db::community::save_community(&v1).unwrap();
6579        // v1 governance: one Admin role, granted to `admin`.
6580        let admin_role = Role::admin("a1".repeat(32));
6581        let roles = CommunityRoles {
6582            roles: vec![admin_role.clone()],
6583            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
6584        };
6585        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
6586
6587        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6588        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
6589
6590        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
6591        let authority = fetch_authority(&bed.relay, &twin).await;
6592        assert!(
6593            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6594            "the v1 admin is an admin on the v2 twin"
6595        );
6596        assert!(
6597            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
6598            "a non-admin gains no authority"
6599        );
6600    }
6601
6602    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
6603    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
6604    /// probe returning only a stranger's record would permanently stop the sweep before the
6605    /// owner's real carrier is ever fetched.
6606    #[tokio::test]
6607    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
6608        use crate::community::migration;
6609        let (bed, owner, stranger) = TestBed::new();
6610
6611        bed.swap_to(&owner);
6612        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6613        let v1_cid = v1.id.to_hex();
6614        v1.owner_attestation = Some({
6615            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6616                .finalize(&owner.keys).unwrap().as_json()
6617        });
6618        crate::db::community::save_community(&v1).unwrap();
6619
6620        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
6621        // the community is locally sealed (as if folded on an old build) but not yet checked.
6622        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
6623        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
6624        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
6625        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
6626
6627        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
6628        migration::sweep_dissolved_for_migration(&bed.relay).await;
6629        assert!(
6630            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6631            "a stranger-only probe must not converge the sweep"
6632        );
6633
6634        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
6635        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
6636        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
6637        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
6638        migration::sweep_dissolved_for_migration(&bed.relay).await;
6639        assert!(
6640            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
6641            "an owner plain-dissolution converges the sweep"
6642        );
6643    }
6644
6645    /// Wizard preflight refuses before the timelock and for non-owners.
6646    #[tokio::test]
6647    async fn wizard_preflight_gates_timelock_and_ownership() {
6648        use crate::community::migration;
6649        let (bed, owner, _member) = TestBed::new();
6650        bed.swap_to(&owner);
6651        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6652        v1.owner_attestation = Some({
6653            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
6654                .finalize(&owner.keys).unwrap().as_json()
6655        });
6656        crate::db::community::save_community(&v1).unwrap();
6657
6658        // Before the unlock → refused, nothing published.
6659        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
6660        assert!(err.contains("not unlocked"), "{err}");
6661        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
6662    }
6663
6664    #[tokio::test]
6665    async fn public_link_full_loop() {
6666        let (bed, owner, member) = TestBed::new();
6667
6668        bed.swap_to(&owner);
6669        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
6670        let general = community.channels[0].id;
6671        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
6672        // Mint a shareable link (a non-stock relay so the fragment carries it).
6673        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6674        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
6675        assert!(link.url.contains('#'), "the fragment carries the token");
6676
6677        // Member joins purely from the URL string.
6678        bed.swap_to(&member);
6679        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6680        assert_eq!(joined.id().0, community.id().0);
6681        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
6682    }
6683
6684    #[test]
6685    fn bundle_of_snapshots_the_held_icon() {
6686        let owner = Keys::generate();
6687        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
6688        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
6689        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
6690        c.icon = Some(icon.clone());
6691        let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
6692        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
6693    }
6694
6695    #[test]
6696    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
6697        // follow_rekeys' fetch fan AND streamauth's plane registration share
6698        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
6699        // MUST include archived roots or an AUTH-gated relay never serves the
6700        // rotation crate → the channel stalls at its old epoch.
6701        let (_tmp, _guard, _owner) = init_test_db();
6702        let cur_root = [9u8; 32];
6703        let cid = crate::community::CommunityId([1u8; 32]);
6704        let cid_hex = cid.to_hex();
6705
6706        // No archives yet → just the current root.
6707        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6708        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
6709
6710        // Archive two prior roots (freshest-first ordering is asserted below).
6711        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
6712        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
6713        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6714        assert_eq!(roots[0], cur_root, "current root leads");
6715        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
6716        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
6717        // Freshest-archived-first (epoch 1 before epoch 0).
6718        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
6719
6720        // A stored root equal to the CURRENT one must not duplicate.
6721        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
6722        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6723        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
6724
6725        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
6726        for e in 3..20u64 {
6727            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
6728        }
6729        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
6730        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
6731    }
6732
6733    #[tokio::test]
6734    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
6735        let (bed, owner, member) = TestBed::new();
6736
6737        bed.swap_to(&owner);
6738        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
6739        // The icon lives on the Control Plane, never in the bundle — publish it
6740        // as a metadata edition so the preview must FOLD to see it.
6741        let icon = control::ImageRef {
6742            url: "https://blossom.example/soap".into(),
6743            key: "k".into(),
6744            nonce: "n".into(),
6745            hash: "h".into(),
6746            extra: Default::default(),
6747        };
6748        let mut meta = community.metadata();
6749        meta.icon = Some(icon.clone());
6750        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
6751        // An any-host base — the naddr#fragment payload is domain-agnostic.
6752        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
6753
6754        // A NON-member previews: the real name + the live icon, nothing persisted.
6755        bed.swap_to(&member);
6756        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
6757        assert_eq!(preview.name, "Soapbox");
6758        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
6759        assert!(
6760            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
6761            "previewing must not persist a membership"
6762        );
6763    }
6764
6765    #[tokio::test]
6766    async fn a_previewed_join_reuses_the_verified_fold() {
6767        let (bed, owner, member) = TestBed::new();
6768        bed.swap_to(&owner);
6769        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
6770        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6771
6772        bed.swap_to(&member);
6773        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
6774        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6775        assert_eq!(joined.id().0, community.id().0);
6776        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
6777        // The slot was CONSUMED by the join — proving the handoff path ran (a
6778        // verify re-walk would have left the preview's entry in place).
6779        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
6780    }
6781
6782    #[tokio::test]
6783    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
6784        let (bed, owner, member) = TestBed::new();
6785        bed.swap_to(&owner);
6786        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
6787        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6788
6789        bed.swap_to(&member);
6790        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
6791
6792        // Seed from zero: the stored fold equals the authoritative live fold.
6793        let session = SessionGuard::capture();
6794        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
6795        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6796        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
6797        assert!(cursor > 0, "the cursor advanced past zero");
6798        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
6799        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
6800        assert_eq!(stored, live, "stored fold == live fold after the seed");
6801        assert!(stored.contains(&member.keys.public_key()));
6802
6803        // Nothing new on the plane → an idle re-sync folds nothing.
6804        assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
6805
6806        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
6807        bed.swap_to(&owner);
6808        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6809        bed.swap_to(&member);
6810        let session = SessionGuard::capture();
6811        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
6812        assert!(
6813            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
6814            "an owner kick removes the member from the stored fold"
6815        );
6816    }
6817
6818    #[tokio::test]
6819    async fn a_preview_then_revoke_still_refuses_the_join() {
6820        let (bed, owner, member) = TestBed::new();
6821        bed.swap_to(&owner);
6822        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
6823        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6824
6825        // Member previews (warming the verified handoff), THEN the owner revokes.
6826        bed.swap_to(&member);
6827        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
6828        assert_eq!(p.name, "RevokeRace");
6829        bed.swap_to(&owner);
6830        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6831        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6832
6833        // The join MUST refuse: the handoff skips only the root re-verify, never
6834        // the bundle re-fetch that carries the revocation gate.
6835        bed.swap_to(&member);
6836        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6837        assert!(err.contains("revoked"), "got: {err}");
6838    }
6839
6840    #[tokio::test]
6841    async fn a_revoked_link_refuses_to_join() {
6842        let (bed, owner, member) = TestBed::new();
6843        bed.swap_to(&owner);
6844        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
6845        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6846        // Owner retires the link (re-posts the coordinate as a tombstone).
6847        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6848        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
6849
6850        bed.swap_to(&member);
6851        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
6852        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
6853    }
6854
6855    #[tokio::test]
6856    async fn an_expired_direct_invite_refuses_to_join() {
6857        let (bed, owner, member) = TestBed::new();
6858        bed.swap_to(&owner);
6859        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
6860        // Hand-mint an invite that expired in the past.
6861        let inviter = owner.keys.clone();
6862        let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
6863        bundle.expires_at = Some(1_000); // unix ms, long past
6864        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
6865        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6866
6867        bed.swap_to(&member);
6868        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6869        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6870        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
6871    }
6872
6873    #[tokio::test]
6874    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
6875        // The revocation-durability fix: if ANY signer-valid tombstone is among the
6876        // fetched events, refuse — even when a Live bundle is returned FIRST (the
6877        // production union has no newest-first sort, so a stale relay's Live can lead).
6878        let (bed, owner, member) = TestBed::new();
6879        bed.swap_to(&owner);
6880        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
6881        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6882        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
6883
6884        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
6885        // `events.first()` would join the Live; the scan-all fix must refuse.
6886        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
6887
6888        bed.swap_to(&member);
6889        let err = accept_public_link(&union, &link.url).await.unwrap_err();
6890        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
6891    }
6892
6893    #[test]
6894    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
6895        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
6896        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
6897        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
6898        // cap, which trips even earlier — but the count guard is the real defense
6899        // for the single-layer public-link bundle.)
6900        let owner = Keys::generate();
6901        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6902        let hex = crate::simd::hex::bytes_to_hex_32;
6903        let root = [0x11u8; 32];
6904        let mut bundle = CommunityInvite {
6905            community_id: hex(&identity.community_id.0),
6906            owner: hex(&identity.owner_xonly),
6907            owner_salt: hex(&identity.owner_salt),
6908            community_root: hex(&root),
6909            root_epoch: 0,
6910            channels: vec![],
6911            relays: vec!["wss://r".into()],
6912            name: "X".into(),
6913            icon: None,
6914            expires_at: None,
6915            creator_npub: None,
6916            label: None,
6917            extra: Default::default(),
6918        };
6919        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
6920            .map(|i| {
6921                let mut id = [0u8; 32];
6922                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
6923                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
6924            })
6925            .collect();
6926        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
6927    }
6928
6929    #[tokio::test]
6930    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
6931        // The SessionGuard straddle: a public-link accept fetches then saves. If the
6932        // account swaps in that window, the join must abort — never write A's
6933        // community into B's DB. SwapMidFetch bumps the session generation during
6934        // the fetch await, exactly as a real swap_session would.
6935        let (bed, owner, member) = TestBed::new();
6936        bed.swap_to(&owner);
6937        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
6938        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
6939        // A fresh swap-injecting transport holding the same bundle event.
6940        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6941        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
6942
6943        bed.swap_to(&member);
6944        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
6945        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
6946        assert!(
6947            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6948            "the aborted join wrote nothing to the (member) account DB"
6949        );
6950    }
6951
6952    #[tokio::test]
6953    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
6954        // The owner is derived from the self-certifying community_id, so the
6955        // memberlist includes them independent of any Guestbook fetch.
6956        let (_tmp, _guard, owner) = init_test_db();
6957        let relay = MemoryRelay::new();
6958        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
6959        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
6960        // still contains the owner.
6961        let empty = MemoryRelay::new();
6962        let members = memberlist(&empty, &community).await.unwrap();
6963        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
6964    }
6965
6966    #[tokio::test]
6967    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
6968        // The mint path can now produce an expiring invite, and the accept gate
6969        // trips on it (end-to-end through the real service, not a hand-built bundle).
6970        let (bed, owner, member) = TestBed::new();
6971        bed.swap_to(&owner);
6972        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
6973        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
6974            .await
6975            .unwrap();
6976
6977        bed.swap_to(&member);
6978        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6979        assert!(
6980            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
6981            "a minted expiring invite refuses past its deadline"
6982        );
6983    }
6984
6985    #[tokio::test]
6986    async fn a_member_who_leaves_drops_from_the_memberlist() {
6987        let (bed, owner, member) = TestBed::new();
6988        bed.swap_to(&owner);
6989        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
6990        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6991
6992        bed.swap_to(&member);
6993        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6994        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6995        // Let the leave land strictly after the join.
6996        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
6997        leave_community(&bed.relay, &joined).await.unwrap();
6998
6999        bed.swap_to(&owner);
7000        let members = memberlist(&bed.relay, &community).await.unwrap();
7001        assert!(members.contains(&owner.keys.public_key()));
7002        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7003    }
7004
7005    #[tokio::test]
7006    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7007        // Multi-account isolation: after the swap, the member's DB holds nothing
7008        // of the owner's community — the dual-stack storage is per-account.
7009        let (bed, owner, member) = TestBed::new();
7010        bed.swap_to(&owner);
7011        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7012        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7013
7014        bed.swap_to(&member);
7015        assert!(
7016            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7017            "the owner's community must be invisible in the member's account DB"
7018        );
7019        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7020    }
7021
7022    // ── Live control-follow ──────────────────────────────────────────────────
7023
7024    /// Publish an owner-grammar channel edition straight to the control plane,
7025    /// signed by `signer` (the owner for a legit edit, a stranger for the
7026    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
7027    /// The entity's current head `self_hash` on the relay (highest version wins),
7028    /// so a helper can chain a new edition the way a real owner client does.
7029    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7030        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7031        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7032        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7033        let mut head: Option<(u64, [u8; 32])> = None;
7034        for w in &wraps {
7035            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7036                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7037                    head = Some((ed.version, ed.self_hash));
7038                }
7039            }
7040        }
7041        head.map(|(_, h)| h)
7042    }
7043
7044    /// The `vac` a non-owner signer must attach, read off the Grant they were
7045    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
7046    /// real client does via `my_authority_citation`, so the fixtures publish the
7047    /// shape Vector actually emits.
7048    async fn cite_on_relay(
7049        relay: &MemoryRelay,
7050        community: &CommunityV2,
7051        signer: &Keys,
7052    ) -> Option<crate::community::edition::AuthorityCitation> {
7053        if community.owner().ok() == Some(signer.public_key()) {
7054            return None;
7055        }
7056        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7057        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7058        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7059        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7060        let mut head: Option<(u64, [u8; 32])> = None;
7061        for w in &wraps {
7062            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7063                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7064                    head = Some((ed.version, ed.self_hash));
7065                }
7066            }
7067        }
7068        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7069    }
7070
7071    async fn publish_channel_edition(
7072        relay: &MemoryRelay,
7073        community: &CommunityV2,
7074        signer: &Keys,
7075        channel_id: &ChannelId,
7076        name: &str,
7077        private: bool,
7078        version: u64,
7079        deleted: bool,
7080    ) {
7081        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7082        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7083        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7084        let content = serde_json::to_string(&meta).unwrap();
7085        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7086        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7087        relay.publish(&wrap, &community.relays).await.unwrap();
7088    }
7089
7090    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
7091    /// to the current relay head like a real owner client.
7092    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7093        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7094    }
7095
7096    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
7097    /// relay-side newest-first ordering (paging/eviction scenarios).
7098    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7099        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7100        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7101        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7102        let content = serde_json::to_string(&meta).unwrap();
7103        let cite = cite_on_relay(relay, community, signer).await;
7104        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());
7105        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7106        relay.publish(&wrap, &community.relays).await.unwrap();
7107    }
7108
7109    #[test]
7110    fn metadata_apply_captures_undriven_fields_for_republish() {
7111        let owner = Keys::generate();
7112        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7113        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7114        let general = held.channels[0].id;
7115
7116        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
7117        let mut custom = serde_json::Map::new();
7118        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7119        let mut extra = serde_json::Map::new();
7120        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7121        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7122        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7123        assert_eq!(held.meta_custom, Some(custom.clone()));
7124        assert_eq!(held.meta_extra, extra);
7125        // …and the next local edit's base document republishes them verbatim.
7126        assert_eq!(held.metadata().custom, Some(custom));
7127        assert_eq!(held.metadata().extra, held.meta_extra);
7128
7129        // Same contract for a vsk-2 channel head (voice included).
7130        let mut ch_custom = serde_json::Map::new();
7131        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7132        let ch_meta = control::ChannelMetadata {
7133            name: "general".into(),
7134            private: false,
7135            voice: Some(true),
7136            deleted: None,
7137            custom: Some(ch_custom.clone()),
7138            extra: Default::default(),
7139        };
7140        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7141        let ch = held.channel(&general).unwrap();
7142        assert_eq!(ch.voice, Some(true));
7143        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7144        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7145        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7146        assert_eq!(rename.custom, Some(ch_custom));
7147    }
7148
7149    #[test]
7150    fn community_metadata_apply_sets_and_clears_images() {
7151        let owner = Keys::generate();
7152        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7153        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7154
7155        let icon = control::ImageRef {
7156            url: "https://blossom.example/i".into(),
7157            key: "k".into(),
7158            nonce: "n".into(),
7159            hash: "h".into(),
7160            extra: Default::default(),
7161        };
7162        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7163        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7164        assert_eq!(held.icon.as_ref(), Some(&icon));
7165
7166        // An edition is the FULL document: a head without the icon removes it.
7167        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7168        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7169        assert_eq!(held.icon, None);
7170    }
7171
7172    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
7173    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7174        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7175        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7176        let prev = head_hash_on_relay(relay, community, &role_id).await;
7177        let content = crate::community::v2::roles::role_content_json(role).unwrap();
7178        let cite = cite_on_relay(relay, community, signer).await;
7179        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7180        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7181        relay.publish(&wrap, &community.relays).await.unwrap();
7182    }
7183
7184    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
7185    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7186        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7187        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7188        let prev = head_hash_on_relay(relay, community, &eid).await;
7189        let grant = MemberGrant { member: member.to_hex(), role_ids };
7190        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7191        let cite = cite_on_relay(relay, community, signer).await;
7192        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7193        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7194        relay.publish(&wrap, &community.relays).await.unwrap();
7195    }
7196
7197    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
7198    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7199        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7200        let eid = crate::community::v2::derive::banlist_locator(community.id());
7201        let prev = head_hash_on_relay(relay, community, &eid).await;
7202        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7203        let cite = cite_on_relay(relay, community, signer).await;
7204        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7205        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7206        relay.publish(&wrap, &community.relays).await.unwrap();
7207    }
7208
7209    fn admin_role(role_id: &str, perms: u64) -> Role {
7210        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7211    }
7212
7213    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
7214    // any control edition) must not be able to SUPPRESS a role or grant by forging a
7215    // higher version at its coordinate. Owner-only signers mask this entirely, so every
7216    // attacker below signs as a NON-owner member.
7217
7218    #[tokio::test]
7219    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
7220        let (bed, owner, attacker) = TestBed::new();
7221        bed.swap_to(&owner);
7222        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
7223        let victim = Keys::generate().public_key();
7224        grant_admin(&bed.relay, &community, &victim).await.unwrap();
7225
7226        // The admin role sits at a deterministic, publicly-computable coordinate.
7227        let admin_rid = fetch_authority(&bed.relay, &community)
7228            .await
7229            .roles
7230            .roles
7231            .iter()
7232            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
7233            .unwrap()
7234            .role_id
7235            .clone();
7236        // Attacker forges v2 of that exact role, stripping its powers.
7237        publish_role(
7238            &bed.relay,
7239            &community,
7240            &attacker.keys,
7241            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
7242            2,
7243        )
7244        .await;
7245
7246        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7247        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
7248        assert!(
7249            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
7250            "the floor advances only to the AUTHORIZED head (owner v1)"
7251        );
7252        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
7253    }
7254
7255    #[tokio::test]
7256    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
7257        let (bed, owner, attacker) = TestBed::new();
7258        bed.swap_to(&owner);
7259        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
7260        let victim = Keys::generate();
7261        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
7262
7263        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
7264        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
7265
7266        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7267        assert!(
7268            authority.roles.is_admin(&victim.public_key().to_hex()),
7269            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
7270        );
7271    }
7272
7273    #[tokio::test]
7274    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
7275        let (bed, owner, attacker) = TestBed::new();
7276        bed.swap_to(&owner);
7277        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
7278        let victim = Keys::generate().public_key();
7279        grant_admin(&bed.relay, &community, &victim).await.unwrap();
7280
7281        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
7282        // unauthorized, so the post-authorize cap never sees them.
7283        for i in 0u8..6 {
7284            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
7285            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7286        }
7287
7288        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7289        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
7290        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
7291    }
7292
7293    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
7294    /// roster + banlist — two clients converge iff these match.
7295    fn authority_fingerprint(a: &AuthoritySet) -> String {
7296        let mut roles = a.roles.roles.clone();
7297        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
7298        let mut grants = a.roles.grants.clone();
7299        for g in &mut grants {
7300            g.role_ids.sort();
7301        }
7302        grants.sort_by(|x, y| x.member.cmp(&y.member));
7303        let banned: Vec<&String> = a.banned.iter().collect();
7304        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
7305    }
7306
7307    #[tokio::test]
7308    async fn the_v2_authority_fold_is_order_independent() {
7309        // THE core consensus property: two honest clients that receive the SAME
7310        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
7311        // authorized roster + banlist (author-aware select_authorized + banlist
7312        // fold + cap, all deterministic). A divergence here would fork the
7313        // community's moderation state between honest members.
7314        let (bed, owner, _a) = TestBed::new();
7315        bed.swap_to(&owner);
7316        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
7317
7318        // A rich control plane: two admins, an extra role, two grants (one of them a
7319        // grant to a member the owner then bans), a banlist, a rename, a channel.
7320        let admin1 = Keys::generate().public_key();
7321        let admin2 = Keys::generate().public_key();
7322        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
7323        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
7324        let mod_rid = "5c".repeat(32);
7325        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
7326        let member = Keys::generate().public_key();
7327        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
7328        let banned_member = Keys::generate().public_key();
7329        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
7330        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
7331        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
7332        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7333        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7334
7335        let editions = fetch_control(&bed.relay, &community).await;
7336        let floors = load_floors(&community);
7337        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
7338
7339        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
7340
7341        // Fold under many arrival permutations: reversed, and several deterministic
7342        // rotations/interleavings. Every one must match the baseline.
7343        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
7344        let mut rev = editions.clone();
7345        rev.reverse();
7346        orders.push(rev);
7347        for shift in [1usize, 3, 5, 7] {
7348            let n = editions.len();
7349            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
7350        }
7351        // A deterministic "shuffle": interleave from both ends.
7352        let mut zip = Vec::with_capacity(editions.len());
7353        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
7354        while lo <= hi {
7355            zip.push(editions[lo as usize].clone());
7356            if lo != hi {
7357                zip.push(editions[hi as usize].clone());
7358            }
7359            lo += 1;
7360            hi -= 1;
7361        }
7362        orders.push(zip);
7363
7364        for (i, order) in orders.iter().enumerate() {
7365            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
7366            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
7367        }
7368        // Sanity: the fingerprint reflects real state (the banned member is out, the
7369        // honest admins are in).
7370        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
7371        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
7372    }
7373
7374    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
7375    struct FetchErrors(MemoryRelay);
7376    #[async_trait::async_trait]
7377    impl crate::community::transport::Transport for FetchErrors {
7378        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7379        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
7380            self.0.publish(e, r).await
7381        }
7382        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7383            Err("relay down".to_string())
7384        }
7385        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
7386            self.0.publish_durable(e, r).await
7387        }
7388    }
7389
7390    #[tokio::test]
7391    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
7392        let (bed, owner, victim) = TestBed::new();
7393        bed.swap_to(&owner);
7394        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
7395        let victim_hex = victim.keys.public_key().to_hex();
7396        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
7397        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7398        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
7399
7400        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
7401        // return an empty banlist (which would silently un-ban on withheld data).
7402        let down = FetchErrors(MemoryRelay::new());
7403        let view = fetch_authority(&down, &community).await;
7404        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
7405    }
7406
7407    #[tokio::test]
7408    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
7409        let (bed, owner, _m) = TestBed::new();
7410        bed.swap_to(&owner);
7411        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
7412        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7413        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
7414        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
7415
7416        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
7417        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7418        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7419        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
7420        let session = crate::state::SessionGuard::capture();
7421        follow_control(&bed.relay, &community, &session).await.unwrap();
7422        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
7423
7424        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
7425        // drops both admins yet raises no gap. The completeness gate must RETAIN the
7426        // stored roster rather than persist the lossy one.
7427        let relay2 = MemoryRelay::new();
7428        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
7429        follow_control(&relay2, &community, &session).await.unwrap();
7430        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7431        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
7432    }
7433
7434    #[tokio::test]
7435    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
7436        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
7437        // Vector already gated roles and grants in-fold; metadata, channels and
7438        // the banlist resolved on permission alone, so a client one sweep behind
7439        // honored an edit from an admin whose demotion it had not read yet.
7440        let (_tmp, _guard, owner) = init_test_db();
7441        let relay = MemoryRelay::new();
7442        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7443        let admin = Keys::generate();
7444        let rid = "a7".repeat(32);
7445        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
7446        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
7447
7448        // The admin acts WITHOUT citing (what every pre-citation client emitted).
7449        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7450        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
7451        let rumor = control::build_edition_rumor(
7452            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
7453            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
7454            &serde_json::to_string(&meta).unwrap(), 1_000, None,
7455        );
7456        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7457        relay.publish(&wrap, &community.relays).await.unwrap();
7458
7459        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
7460        let victim = Keys::generate().public_key().to_hex();
7461        let ban_rumor = control::build_edition_rumor(
7462            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
7463            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
7464        );
7465        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
7466        relay.publish(&ban_wrap, &community.relays).await.unwrap();
7467
7468        let session = SessionGuard::capture();
7469        let updated = follow_control(&relay, &community, &session).await.unwrap();
7470        assert!(
7471            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
7472            "an uncited metadata edit must not be honored",
7473        );
7474        let authority = fetch_authority(&relay, &community).await;
7475        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
7476        // The positive case (this same admin, citing, lands) is
7477        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
7478        // helper cites, so it proves the gate is the CITATION and not the
7479        // permission. Re-proving it here would need a fresh chain anyway: a
7480        // cited edition chaining onto the rejected one above is gapped, not
7481        // refused.
7482    }
7483
7484    #[tokio::test]
7485    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
7486        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
7487        // owner revokes the grant, the (now unauthorized) admin's further edit drops
7488        // and the name holds at the last authorized state.
7489        let (_tmp, _guard, owner) = init_test_db();
7490        let relay = MemoryRelay::new();
7491        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
7492        let admin = Keys::generate();
7493        let rid = "a1".repeat(32);
7494        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7495        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7496        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
7497
7498        let session = SessionGuard::capture();
7499        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
7500        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
7501
7502        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
7503        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
7504        let _ = follow_control(&relay, &community, &session).await.unwrap();
7505        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7506        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
7507    }
7508
7509    #[tokio::test]
7510    async fn a_roleless_member_cannot_edit_metadata() {
7511        let (_tmp, _guard, _owner) = init_test_db();
7512        let relay = MemoryRelay::new();
7513        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
7514        let stranger = Keys::generate();
7515        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
7516        let session = SessionGuard::capture();
7517        assert!(
7518            follow_control(&relay, &community, &session).await.unwrap().is_none(),
7519            "a roleless member's metadata edit never folds"
7520        );
7521    }
7522
7523    #[tokio::test]
7524    async fn a_self_signed_grant_is_not_authority() {
7525        // The self-promotion defense: a member self-signs both a role and a grant of
7526        // it to themselves. authorize_delegation drops both (their signer never traces
7527        // to the owner), so their metadata edit stays unauthorized.
7528        let (_tmp, _guard, _owner) = init_test_db();
7529        let relay = MemoryRelay::new();
7530        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
7531        let rogue = Keys::generate();
7532        let rid = "b2".repeat(32);
7533        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7534        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
7535        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
7536        let session = SessionGuard::capture();
7537        assert!(
7538            follow_control(&relay, &community, &session).await.unwrap().is_none(),
7539            "a self-signed grant confers no authority"
7540        );
7541    }
7542
7543    #[tokio::test]
7544    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
7545        let (_tmp, _guard, owner) = init_test_db();
7546        let relay = MemoryRelay::new();
7547        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
7548        let target = "cc".repeat(32);
7549
7550        // A non-BAN-holder's banlist edition is folded but NOT enforced.
7551        let rogue = Keys::generate();
7552        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
7553        let floors = load_floors(&community);
7554        let editions = fetch_control(&relay, &community).await;
7555        let authority = fold_authority(&community, &editions, &floors);
7556        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
7557
7558        // The owner (supreme, holds BAN) bans the target: now enforced.
7559        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
7560        let editions = fetch_control(&relay, &community).await;
7561        let authority = fold_authority(&community, &editions, &floors);
7562        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
7563    }
7564
7565    #[tokio::test]
7566    async fn a_banned_admin_loses_all_authority() {
7567        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
7568        // banned admin's authority is dropped and their edits refused.
7569        let (_tmp, _guard, owner) = init_test_db();
7570        let relay = MemoryRelay::new();
7571        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
7572        let admin = Keys::generate();
7573        let rid = "e5".repeat(32);
7574        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
7575        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
7576        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
7577        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
7578
7579        let session = SessionGuard::capture();
7580        assert!(
7581            follow_control(&relay, &community, &session).await.unwrap().is_none(),
7582            "a banned admin's edit is dropped even with an unstripped grant"
7583        );
7584        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7585        assert!(authority.banned.contains(&admin.public_key().to_hex()));
7586        assert!(
7587            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
7588            "a banned admin holds no bit"
7589        );
7590    }
7591
7592    #[tokio::test]
7593    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
7594        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
7595        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
7596        // and NOT the owner (supreme, unbannable).
7597        let (_tmp, _guard, owner) = init_test_db();
7598        let relay = MemoryRelay::new();
7599        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
7600        let admin = Keys::generate();
7601        let moder = Keys::generate();
7602        let stranger = Keys::generate();
7603        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
7604        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;
7605        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;
7606        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
7607        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
7608        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
7609
7610        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7611        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
7612        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
7613        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
7614    }
7615
7616    #[tokio::test]
7617    async fn an_unauthorized_higher_banlist_cannot_unban() {
7618        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
7619        // must not erase the owner's ban (author-aware head selection + persisted
7620        // banlist retention).
7621        let (_tmp, _guard, owner) = init_test_db();
7622        let relay = MemoryRelay::new();
7623        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
7624        let target = "cc".repeat(32);
7625        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
7626        let session = SessionGuard::capture();
7627        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
7628
7629        let rogue = Keys::generate();
7630        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
7631        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
7632        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
7633    }
7634
7635    #[tokio::test]
7636    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
7637        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
7638        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
7639        let (_tmp, _guard, _owner) = init_test_db();
7640        let relay = MemoryRelay::new();
7641        let relays = vec!["wss://r".to_string()];
7642        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
7643        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
7644        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
7645
7646        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7647        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
7648        assert_eq!(rehydrated[0].id().0, community.id().0);
7649        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
7650    }
7651
7652    #[tokio::test]
7653    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
7654        let (_tmp, _guard, _owner) = init_test_db();
7655        let relay = MemoryRelay::new();
7656        let relays = vec!["wss://r".to_string()];
7657        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
7658        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
7659
7660        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
7661        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
7662    }
7663
7664    #[tokio::test]
7665    async fn accepting_the_same_bundle_twice_is_idempotent() {
7666        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
7667        // again must upsert cleanly — same community_id, no duplicate channels, no
7668        // corruption, the keys unchanged.
7669        let (bed, owner, member) = TestBed::new();
7670        bed.swap_to(&owner);
7671        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
7672        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7673        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7674        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7675
7676        bed.swap_to(&member);
7677        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7678        let channels_after_first = first.channels.len();
7679        let root_after_first = first.community_root;
7680
7681        // Accept the identical bundle again (restart / redelivery).
7682        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
7683        assert_eq!(second.id().0, first.id().0, "same community_id");
7684        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
7685        assert_eq!(second.community_root, root_after_first, "root unchanged");
7686
7687        // The persisted state is a single clean community with the expected channels.
7688        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7689        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
7690        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
7691    }
7692
7693    #[tokio::test]
7694    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
7695        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
7696        // severs a member; the owner then unbans + sends a FRESH invite carrying the
7697        // NEW root; the member rejoins at the new epoch and converses again. Proves
7698        // a ban is reversible end-to-end, not a one-way door.
7699        let (bed, owner, member) = TestBed::new();
7700        bed.swap_to(&owner);
7701        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
7702        let general = community.channels[0].id;
7703        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7704        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
7705
7706        bed.swap_to(&member);
7707        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7708        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
7709        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
7710
7711        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
7712        bed.swap_to(&owner);
7713        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
7714        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
7715        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7716        assert_eq!(community.root_epoch, Epoch(1));
7717        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
7718
7719        // The member's follow concludes severance (no blob at the new epoch).
7720        bed.swap_to(&member);
7721        let session = SessionGuard::capture();
7722        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
7723
7724        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
7725        // directly, so the test picks the NEW invite unambiguously rather than an
7726        // arbitrary one of the two pending 3313s).
7727        bed.swap_to(&owner);
7728        set_banlist(&bed.relay, &community, &[]).await.unwrap();
7729        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7730        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
7731        let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
7732
7733        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
7734        bed.swap_to(&member);
7735        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
7736        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
7737        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
7738        let seen = texts_in(&bed.relay, &rejoined, &general).await;
7739        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
7740        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
7741
7742        bed.swap_to(&owner);
7743        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7744        assert!(
7745            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
7746            "the re-admitted member converses again at the new epoch"
7747        );
7748        // And they're back in the memberlist.
7749        let members = memberlist(&bed.relay, &community).await.unwrap();
7750        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
7751    }
7752
7753    #[tokio::test]
7754    async fn dissolution_blocks_a_join() {
7755        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
7756        // refuses to join.
7757        let (bed, owner, member) = TestBed::new();
7758        bed.swap_to(&owner);
7759        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7760        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7761        let bundle_json = serde_json::to_string(&bundle).unwrap();
7762        dissolve_community(&bed.relay, &community).await.unwrap();
7763        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
7764
7765        bed.swap_to(&member);
7766        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
7767        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
7768    }
7769
7770    #[tokio::test]
7771    async fn dissolution_seals_writes_but_not_reads() {
7772        // CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
7773        // the history stays browsable, and only explicit user intent deletes it.
7774        let (bed, owner, _member) = TestBed::new();
7775        bed.swap_to(&owner);
7776        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7777        let general = community.channels[0].id;
7778        send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
7779
7780        dissolve_community(&bed.relay, &community).await.unwrap();
7781        let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7782
7783        for err in [
7784            send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
7785            send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
7786                .await
7787                .unwrap_err(),
7788            send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
7789        ] {
7790            assert!(err.contains("dissolved"), "every write is refused, got: {err}");
7791        }
7792        assert!(
7793            texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
7794            "but the history still reads"
7795        );
7796    }
7797
7798    #[tokio::test]
7799    async fn only_the_owner_can_dissolve() {
7800        let (bed, owner, member) = TestBed::new();
7801        bed.swap_to(&owner);
7802        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
7803        bed.swap_to(&member);
7804        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
7805        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
7806    }
7807
7808    #[tokio::test]
7809    async fn a_foreign_tombstone_is_not_death() {
7810        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
7811        // owner-gated), so the community is not treated as dead.
7812        let (_tmp, _guard, _owner) = init_test_db();
7813        let relay = MemoryRelay::new();
7814        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
7815        let rogue = Keys::generate();
7816        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
7817        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
7818        relay.publish(&wrap, &community.relays).await.unwrap();
7819        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
7820    }
7821
7822    #[tokio::test]
7823    async fn a_public_channel_reads_history_across_a_refounding() {
7824        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
7825        // pre-rotation messages stay readable (the prior epoch's root is archived and
7826        // the read fans out across held epochs).
7827        let (_tmp, _guard, _owner) = init_test_db();
7828        let relay = MemoryRelay::new();
7829        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
7830        let general = community.channels[0].id;
7831        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
7832
7833        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
7834        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7835        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
7836
7837        let texts = texts_in(&relay, &refounded, &general).await;
7838        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
7839        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
7840    }
7841
7842    #[tokio::test]
7843    async fn refounding_aborts_when_control_state_is_withheld() {
7844        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
7845        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
7846        // member at the new epoch a fresh joiner bootstraps).
7847        let (_tmp, _guard, owner) = init_test_db();
7848        let relay = MemoryRelay::new();
7849        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
7850        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
7851        let session = SessionGuard::capture();
7852        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
7853
7854        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
7855        // reloads fresh state) fetches none of the committed heads.
7856        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7857        moved.relays = vec!["wss://empty".into()];
7858        crate::db::community::save_community_v2(&moved).unwrap();
7859
7860        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
7861        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
7862        assert_eq!(
7863            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7864            Epoch(0),
7865            "the epoch did NOT advance (zero published state)"
7866        );
7867    }
7868
7869    #[tokio::test]
7870    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
7871        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
7872        // epoch advances, and the removed member's rekey-follow concludes they're cut.
7873        let (bed, owner, member) = TestBed::new();
7874        bed.swap_to(&owner);
7875        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
7876        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7877        let bundle_json = serde_json::to_string(&bundle).unwrap();
7878        bed.swap_to(&member);
7879        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7880
7881        bed.swap_to(&owner);
7882        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
7883        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
7884        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
7885        // The owner still reads the compacted control plane at the new epoch.
7886        assert_eq!(
7887            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
7888            Epoch(1),
7889            "the owner committed the new epoch"
7890        );
7891
7892        // The removed member, following rekeys, is severed (no blob in the rotation).
7893        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
7894        // swap now bumps the generation exactly like a production swap_session).
7895        bed.swap_to(&member);
7896        let session = SessionGuard::capture();
7897        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
7898        assert!(follow.self_removed, "the removed member is cut by the re-founding");
7899    }
7900
7901    #[tokio::test]
7902    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
7903        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
7904        // non-owner admin granted BAN CAN re-found (and every member follows it —
7905        // see the receive-side test), but the "strictly outrank every removed
7906        // target" rule still holds: they can't use it to evict the owner.
7907        let (bed, owner, member) = TestBed::new();
7908        bed.swap_to(&owner);
7909        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
7910        let rid = "b0".repeat(32);
7911        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7912        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7913        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7914        let bundle_json = serde_json::to_string(&bundle).unwrap();
7915        bed.swap_to(&member);
7916        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7917        // Fold the roster so this member's own DB reflects their BAN grant (the
7918        // authority check reads the folded Roster, not the bundle).
7919        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7920        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7921        // Can't evict the owner (no one outranks the owner).
7922        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
7923        // But CAN re-found removing a plain member they outrank (here, nobody).
7924        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
7925    }
7926
7927    #[tokio::test]
7928    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
7929        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
7930        // follow it — owner-only receive silently strands members whose community
7931        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
7932        // BAN", checked against the folded Roster).
7933        let (bed, owner, me) = TestBed::new();
7934        let admin = Keys::generate();
7935        bed.swap_to(&owner);
7936        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
7937        let rid = "b0".repeat(32);
7938        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7939        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
7940
7941        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
7942        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7943        let bundle_json = serde_json::to_string(&bundle).unwrap();
7944        bed.swap_to(&me);
7945        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7946        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7947        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7948
7949        // The admin re-founds keeping the owner + me — the owner must always be a
7950        // recipient of a non-owner Refounding.
7951        let new_root = [0xC7; 32];
7952        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7953
7954        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7955            .expect("an authorized admin's Refounding is adopted");
7956        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
7957        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
7958    }
7959
7960    #[tokio::test]
7961    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
7962        // CORD-05 §2: a link shared once keeps working across rotations, because
7963        // its bundle is re-posted behind the same URL. The Refounder can only
7964        // refresh the bundles they hold signer secrets for — their OWN — so
7965        // every other creator has to heal their links when they ADOPT the
7966        // rotation. Without that, an admin's links keep vending the superseded
7967        // root and drop new joiners onto a dead epoch, which is precisely the
7968        // stranding the stable-URL refresh exists to prevent.
7969        let (bed, owner, me) = TestBed::new();
7970        bed.swap_to(&owner);
7971        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
7972        let rid = "b1".repeat(32);
7973        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
7974        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
7975
7976        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
7977        let bundle_json = serde_json::to_string(&bundle).unwrap();
7978        bed.swap_to(&me);
7979        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
7980        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
7981        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
7982
7983        // I mint a link of my own at the CURRENT epoch.
7984        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
7985        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7986        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
7987
7988        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
7989        // its signer secret.
7990        let new_root = [0xD4; 32];
7991        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
7992
7993        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
7994            .expect("the owner's Refounding is adopted");
7995        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
7996
7997        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
7998        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
7999        assert_eq!(
8000            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
8001            new_root,
8002            "and the new root behind the same URL",
8003        );
8004    }
8005
8006    #[tokio::test]
8007    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8008        // Authority escalation: a BAN-admin can't use a Refounding to evict the
8009        // OWNER (no one outranks the owner). Excluding them makes the rotation
8010        // inadmissible — members fork-reject it rather than migrate to the coup.
8011        let (bed, owner, me) = TestBed::new();
8012        let admin = Keys::generate();
8013        bed.swap_to(&owner);
8014        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8015        let rid = "b0".repeat(32);
8016        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8017        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8018
8019        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8020        let bundle_json = serde_json::to_string(&bundle).unwrap();
8021        bed.swap_to(&me);
8022        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8023        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8024        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8025
8026        // The admin re-founds delivering to me but NOT the owner — a takeover.
8027        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8028        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8029        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8030    }
8031
8032    #[tokio::test]
8033    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8034        // Authority escalation: two equal-rank BAN-admins — neither strictly
8035        // outranks the other, so one can't Refound the other out. Excluding a
8036        // peer makes the rotation inadmissible.
8037        let (bed, owner, me) = TestBed::new();
8038        let admin_a = Keys::generate();
8039        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
8040        bed.swap_to(&owner);
8041        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8042        let rid = "b0".repeat(32);
8043        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8044        // Both A and B hold the SAME role (same position 1) → peers.
8045        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8046        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8047
8048        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8049        let bundle_json = serde_json::to_string(&bundle).unwrap();
8050        bed.swap_to(&me);
8051        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8052        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8053        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8054
8055        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
8056        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8057
8058        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8059        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8060    }
8061
8062    #[tokio::test]
8063    async fn a_retried_refounding_reuses_the_same_root() {
8064        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
8065        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
8066        let (_tmp, _guard, _owner) = init_test_db();
8067        let relay = MemoryRelay::new();
8068        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8069        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8070        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8071        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8072        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8073    }
8074
8075    #[tokio::test]
8076    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8077        // CORD-04 §2 rank inversion. Minting at a position you outrank is
8078        // necessary but NOT sufficient: an edition replaces the entity, so a
8079        // gate that only reads the NEW position lets an admin at position 5
8080        // rewrite the position-1 role to position 9. Every check passes (9 is
8081        // beneath them), and the role that outranked them — plus everyone
8082        // holding it — is now beneath them.
8083        let (bed, owner, attacker) = TestBed::new();
8084        bed.swap_to(&owner);
8085        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8086
8087        // A senior role at position 1, and a mid role at position 5 the attacker holds.
8088        let senior = "a1".repeat(32);
8089        let mid = "a5".repeat(32);
8090        publish_role(&bed.relay, &community, &owner.keys,
8091            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8092        publish_role(&bed.relay, &community, &owner.keys,
8093            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8094        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8095
8096        // The attacker republishes the SENIOR role, dropping it beneath themselves.
8097        publish_role(&bed.relay, &community, &attacker.keys,
8098            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8099
8100        let authority = fetch_authority(&bed.relay, &community).await;
8101        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8102        assert_eq!(
8103            folded_senior.position, 1,
8104            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8105        );
8106    }
8107
8108    #[tokio::test]
8109    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8110        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
8111        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
8112        // an uncited Vector admin's ban/role/channel edit was silently dropped
8113        // by every Armada client — only the owner's actions crossed. The
8114        // citation must name the actor's OWN grant coordinate, at the version
8115        // and edition hash the verifier can match against a grant it holds.
8116        let (bed, owner, admin) = TestBed::new();
8117        bed.swap_to(&owner);
8118        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8119        let rid = "c1".repeat(32);
8120        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8121        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8122
8123        // The owner's own edition carries NO citation: their rank is the id.
8124        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8125        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8126        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8127            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8128            .max_by_key(|e| e.version).expect("the owner's metadata edition");
8129        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8130
8131        // The admin JOINS and folds — the citation names the grant head their own
8132        // client has actually synced, so the fold must have persisted it.
8133        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8134        let bundle_json = serde_json::to_string(&bundle).unwrap();
8135        bed.swap_to(&admin);
8136        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8137        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8138        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8139        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8140
8141        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8142            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8143            .expect("the admin's banlist edition");
8144        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8145        assert_eq!(
8146            cite.entity_id,
8147            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8148            "the citation must name the ACTOR'S OWN grant coordinate",
8149        );
8150        assert!(cite.version >= 1, "pinned to a real grant version");
8151    }
8152
8153    #[tokio::test]
8154    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8155        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
8156        // fold is a boundary like any other: MANAGE_METADATA makes an editor
8157        // authorized, not trusted. An oversize list costs every member a fan-out
8158        // per publish and the slowest of N per fetch — and Armada caps at 5, so
8159        // an uncapped fold also splits the two clients' operative sets.
8160        let (_tmp, _guard, _owner) = init_test_db();
8161        let relay = MemoryRelay::new();
8162        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8163
8164        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8165        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8166        edit_community_metadata(&relay, &community, &meta).await.unwrap();
8167
8168        let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8169            .expect("the metadata edition is folded");
8170        assert_eq!(
8171            updated.relays.len(),
8172            crate::community::MAX_COMMUNITY_RELAYS,
8173            "a folded relay list must be truncated, never adopted whole",
8174        );
8175
8176        // …and the fold must SETTLE: comparing an oversize edition against the
8177        // capped working set would never be equal, so every later fold would
8178        // report a change and re-save forever.
8179        let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8180        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8181    }
8182
8183    #[tokio::test]
8184    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8185        // One Invite List spans every community, so "I hold links" must never be
8186        // read as "I hold links HERE". A member with links elsewhere adopting a
8187        // rotation would otherwise publish an empty Registry edition into this
8188        // community — a control-plane write and a version bump on a coordinate
8189        // they never owned, every rotation, forever.
8190        let (bed, owner, me) = TestBed::new();
8191        bed.swap_to(&owner);
8192        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8193        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8194        let rid = "b2".repeat(32);
8195        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8196        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8197
8198        let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8199        let bundle_json = serde_json::to_string(&bundle).unwrap();
8200        bed.swap_to(&me);
8201        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8202        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8203        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8204
8205        // My only link lives in a DIFFERENT community.
8206        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8207
8208        let before = bed.relay.stored_count();
8209        let new_root = [0xE1; 32];
8210        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8211        let rotation_events = bed.relay.stored_count() - before;
8212
8213        let after_adopt = bed.relay.stored_count();
8214        follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8215        assert_eq!(
8216            bed.relay.stored_count(),
8217            after_adopt,
8218            "adopting a rotation must publish NOTHING when I minted no links here",
8219        );
8220        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
8221    }
8222
8223    #[tokio::test]
8224    async fn an_expired_link_stops_keeping_the_community_public() {
8225        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
8226        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
8227        // the Registry states a door that isn't there — the aggregate never
8228        // empties and the community reads Public forever, silently inverting
8229        // every gate that hangs off that reading.
8230        let (_tmp, _guard, _owner) = init_test_db();
8231        let relay = MemoryRelay::new();
8232        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
8233
8234        // A link that lapsed a minute ago.
8235        let past = now_ms() - 60_000;
8236        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
8237        assert!(
8238            !community_is_public(&relay, &community).await,
8239            "an already-expired link must never read as a live door",
8240        );
8241
8242        // …and one that hasn't, to prove the filter isn't just dropping everything.
8243        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
8244        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
8245    }
8246
8247    #[tokio::test]
8248    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
8249        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
8250        // link publishes it (Public); retiring the last link empties it (Private).
8251        let (_tmp, _guard, _owner) = init_test_db();
8252        let relay = MemoryRelay::new();
8253        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
8254        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
8255
8256        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8257        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8258        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
8259        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
8260
8261        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8262        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8263        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
8264        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
8265        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
8266    }
8267
8268    #[tokio::test]
8269    async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
8270        // Every caller reads the `invite_registry` COLUMN, never the async fold. v2
8271        // published the Registry to the plane but never mirrored it locally, so every
8272        // v2 community read Private no matter how many live links it had.
8273        let (_tmp, _guard, _owner) = init_test_db();
8274        let relay = MemoryRelay::new();
8275        let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
8276        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8277        let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
8278        // The per-creator split is a SEPARATE table, and it drives the "first link flips
8279        // the community Public" confirm — an empty one re-asks on every later link.
8280        let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
8281        assert!(cached().is_empty(), "a fresh community caches an empty registry");
8282        assert!(per_creator().is_empty(), "…and no per-creator sets");
8283
8284        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8285        assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
8286        let sets = per_creator();
8287        assert_eq!(sets.len(), 1, "the minting creator gets a set");
8288        assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
8289
8290        // Both caches must SHRINK too — a union-only mirror would strand it Public.
8291        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8292        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8293        assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
8294        assert!(per_creator().is_empty(), "…and clears the per-creator sets");
8295    }
8296
8297    #[tokio::test]
8298    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
8299        // Registries are coordinate-bound to their creator, but `fold_head` picks an
8300        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
8301        // that freely by varying content. Folding before authorising would let any
8302        // member occupy the owner's registry head, fail the authority check, and drop
8303        // the whole registry: a live invite link silently retired, flipping the
8304        // community to Private and steering a moderator into the wrong ban remedy.
8305        let (_tmp, _guard, owner) = init_test_db();
8306        let relay = MemoryRelay::new();
8307        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8308        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8309        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8310
8311        let cid = community.id();
8312        let control = control_group_key(&community.community_root, cid, community.root_epoch);
8313        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
8314
8315        let query = Query {
8316            kinds: vec![stream::KIND_WRAP],
8317            authors: vec![control.pk_hex()],
8318            limit: Some(FOLLOW_PAGE),
8319            ..Default::default()
8320        };
8321        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8322        let target = wraps
8323            .iter()
8324            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
8325            .filter(|e| e.entity_id == eid)
8326            .max_by_key(|e| e.version)
8327            .expect("the owner published a registry");
8328
8329        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
8330        // real head on the tiebreak (~2 tries against a uniform id).
8331        let rogue = Keys::generate();
8332        let mut planted = false;
8333        for n in 0..4_000u64 {
8334            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
8335            let rumor = control::build_edition_rumor(
8336                rogue.public_key(),
8337                vsk::INVITE_LINKS,
8338                &eid,
8339                target.version,
8340                target.prev_hash.as_ref(),
8341                &content,
8342                9_000,
8343                None,
8344            );
8345            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
8346            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
8347            if ed.inner_id < target.inner_id {
8348                relay.publish(&w, &community.relays).await.unwrap();
8349                planted = true;
8350                break;
8351            }
8352        }
8353        assert!(planted, "the test needs a fork that wins the tiebreak");
8354
8355        assert!(
8356            community_is_public(&relay, &community).await,
8357            "an unauthorised fork must not retire the owner's live link"
8358        );
8359    }
8360
8361    #[tokio::test]
8362    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
8363        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
8364        let (_tmp, _guard, owner) = init_test_db();
8365        let relay = MemoryRelay::new();
8366        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
8367        let rogue = Keys::generate();
8368        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
8369        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
8370        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
8371        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8372        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
8373        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
8374        relay.publish(&wrap, &community.relays).await.unwrap();
8375        let _ = owner;
8376        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
8377    }
8378
8379    #[tokio::test]
8380    async fn full_lifecycle_e2e() {
8381        // The whole stack end to end across two accounts: create -> Public link ->
8382        // owner grants an admin -> member joins + reads history -> admin edits metadata
8383        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
8384        // Refounding) -> the banned member is severed AND stays banned across the new
8385        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
8386        let (bed, owner, member) = TestBed::new();
8387
8388        bed.swap_to(&owner);
8389        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
8390        let general = community.channels[0].id;
8391        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8392
8393        // Public link → the community reads Public.
8394        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
8395        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8396
8397        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
8398        let rid = "aa".repeat(32);
8399        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8400        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8401
8402        // Member joins from the bundle + reads the owner's message.
8403        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8404        let bundle_json = serde_json::to_string(&bundle).unwrap();
8405        bed.swap_to(&member);
8406        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8407        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
8408        // The admin renames the community.
8409        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
8410
8411        // Owner follows: the admin's rename folds (authorized).
8412        bed.swap_to(&owner);
8413        let session = SessionGuard::capture();
8414        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
8415        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
8416
8417        // Ban the member (the three-removal composition, in order).
8418        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
8419        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
8420        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
8421        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8422        // The ban survives the Refounding (the banlist head compacted forward).
8423        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8424        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
8425        // Pre-ban history still reads across the new epoch.
8426        assert!(
8427            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
8428            "pre-refounding history stays readable"
8429        );
8430
8431        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
8432        // the swap (the harness swap bumps the generation like production).
8433        bed.swap_to(&member);
8434        let session = SessionGuard::capture();
8435        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8436        assert!(follow.self_removed, "the banned member is cryptographically cut");
8437
8438        // Owner dissolves → sealed.
8439        bed.swap_to(&owner);
8440        dissolve_community(&bed.relay, &refounded).await.unwrap();
8441        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8442    }
8443
8444    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
8445    /// over one shared relay, create → channels (public + private) → converse both ways →
8446    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
8447    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
8448    /// doubles as a reference transcript and a re-runnable regression.
8449    #[tokio::test]
8450    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
8451        // A member forges a higher-version role edition at the admin coordinate before a
8452        // refounding. The compaction must carry the AUTHORIZED floor head, not the
8453        // author-blind version tip — else the forgery is re-anchored, honest folders drop
8454        // it, and the admin role vanishes at the new epoch (silent suppression).
8455        let (bed, owner, member) = TestBed::new();
8456        let attacker = Keys::generate();
8457        bed.swap_to(&owner);
8458        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
8459        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8460        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8461        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
8462        // Owner folds → the authorized role/grant heads are floored.
8463        let session = SessionGuard::capture();
8464        follow_control(&bed.relay, &community, &session).await.unwrap();
8465        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
8466
8467        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
8468        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;
8469
8470        // Owner refounds (keeping everyone).
8471        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8472        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
8473
8474        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
8475        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8476        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
8477    }
8478
8479    #[tokio::test]
8480    async fn memberlist_survives_a_refounding_via_the_snapshot() {
8481        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
8482        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
8483        let (bed, owner, member) = TestBed::new();
8484        bed.swap_to(&owner);
8485        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
8486
8487        // Member joins (a Guestbook Join at epoch 0).
8488        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8489        bed.swap_to(&member);
8490        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8491        bed.swap_to(&owner);
8492        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
8493
8494        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
8495        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
8496        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
8497
8498        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
8499        let members = memberlist(&bed.relay, &refounded).await.unwrap();
8500        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
8501        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
8502    }
8503
8504    #[tokio::test]
8505    async fn e2e_two_accounts_channels_converse_moderate() {
8506        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
8507        use nostr_sdk::prelude::ToBech32;
8508        let (bed, a, b) = TestBed::new();
8509        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
8510        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
8511        println!("\n===== Concord v2 deep e2e =====");
8512        println!("[acct] A (owner)  = {a_npub}");
8513        println!("[acct] B (member) = {b_npub}");
8514
8515        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
8516        bed.swap_to(&a);
8517        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
8518        let general = community.channels[0].id;
8519        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
8520
8521        // A PRIVATE channel via the REAL create path: an independent key minted at
8522        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
8523        // then announced (vsk 2) — later carried to B in the join bundle.
8524        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8525        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8526        let priv_ch = community.channel(&priv_id).unwrap();
8527        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
8528        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
8529
8530        // Two more PUBLIC channels via the real create path.
8531        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
8532        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8533        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
8534        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8535        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
8536        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
8537
8538        // A talks in a few channels.
8539        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
8540        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
8541        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
8542        println!("[msg] A posted in #general / #announcements / #mods");
8543
8544        // ── A grants B admin, mints a public link, B joins from the bundle ──
8545        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
8546        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
8547        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
8548        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
8549        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
8550        println!("[invite] granted B @admin · minted link {}", link.url);
8551
8552        // A private channel is readable only by granted role-holders (CORD-03), so
8553        // B is added to its access list before the bundle is minted.
8554        grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
8555        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
8556        bed.swap_to(&b);
8557        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8558        println!("[join] B joined; sees {} channels", b_view.channels.len());
8559        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
8560        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");
8561        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
8562        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");
8563        // B folds the control plane (persisting the roster) — the live worker does
8564        // this right after any join; B's admin standing gates B's channel ops below.
8565        let session_b = SessionGuard::capture();
8566        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
8567            b_view = fresh;
8568        }
8569        println!("[follow] B folded control (roster persisted: B is @admin)");
8570
8571        // ── Conversation both ways + persistence (get_messages-level) ──
8572        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
8573        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
8574        println!("[msg] B replied in #general + #mods");
8575        // Persist B's own #general view into the shared store (what sync/live ingest does)
8576        // and confirm it reads back via STATE — get_messages parity.
8577        let my_pk = b.keys.public_key();
8578        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
8579        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
8580            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
8581            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
8582        }
8583        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
8584        println!("[persist] #general history persisted into the shared events store");
8585
8586        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
8587        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
8588        bed.swap_to(&a);
8589        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
8590        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
8591        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
8592        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
8593        println!("[ops] reaction + edit + delete round-tripped");
8594
8595        // ── B creates a channel as admin, A folds it in ──
8596        bed.swap_to(&b);
8597        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
8598        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
8599        bed.swap_to(&a);
8600        let session = SessionGuard::capture();
8601        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
8602            community = updated;
8603        }
8604        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
8605        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
8606
8607        // ── A creates a SECOND private channel while B is already a member. B is
8608        // NOT on its access list, so B learns the channel exists (control-follow,
8609        // keyless) and gets no key: CORD-03's private channel is readable only by
8610        // granted role-holders, never by every member. B keys up if and when A
8611        // grants them the channel's access role and vends the key ──
8612        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8613        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8614        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
8615        println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
8616        bed.swap_to(&b);
8617        let session_b2 = SessionGuard::capture();
8618        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
8619            b_view = fresh;
8620        }
8621        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
8622        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
8623        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
8624        if let Some(fresh) = rf.updated {
8625            b_view = fresh;
8626        }
8627        let ch = b_view.channel(&vault).expect("still recorded");
8628        assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
8629        assert!(
8630            texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
8631            "and reads nothing from it"
8632        );
8633        assert!(
8634            send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
8635            "an unentitled member cannot post into the channel either"
8636        );
8637        bed.swap_to(&a);
8638        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8639        println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
8640
8641        // ── Members ──
8642        let members = memberlist(&bed.relay, &community).await.unwrap();
8643        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
8644        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
8645        println!("[members] {} members: A + B present", members.len());
8646
8647        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
8648        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
8649        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
8650        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
8651        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
8652        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8653        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
8654        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");
8655        assert!(
8656            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
8657            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
8658        );
8659        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
8660        // B concludes it's severed.
8661        bed.swap_to(&b);
8662        let session_b3 = SessionGuard::capture();
8663        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
8664        println!("[ban] B's rekey-follow: self_removed = true (severed)");
8665
8666        // ── Unban: A lifts the ban ──
8667        bed.swap_to(&a);
8668        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
8669        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
8670        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
8671        println!("[unban] B removed from the banlist (re-invitable)");
8672
8673        // ── Dissolve ──
8674        dissolve_community(&bed.relay, &refounded).await.unwrap();
8675        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
8676        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
8677    }
8678
8679    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
8680    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
8681    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
8682    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
8683    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
8684    #[tokio::test]
8685    #[ignore]
8686    async fn live_e2e_two_accounts() {
8687        use crate::community::transport::LiveTransport;
8688        use nostr_sdk::prelude::ToBech32;
8689
8690        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
8691        let relays = vec![relay.clone()];
8692        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
8693        crate::db::close_database();
8694        crate::db::clear_id_caches();
8695        let tmp = tempfile::tempdir().unwrap();
8696        crate::db::set_app_data_dir(tmp.path().to_path_buf());
8697
8698        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
8699        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8700        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
8701
8702        let log = |line: String| {
8703            println!("{line}");
8704            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
8705                use std::io::Write;
8706                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
8707                    let _ = writeln!(f, "{line}");
8708                }
8709            }
8710        };
8711        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
8712        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
8713        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
8714
8715        for k in [&a, &b] {
8716            let npub = k.public_key().to_bech32().unwrap();
8717            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
8718            crate::db::set_current_account(npub.clone()).unwrap();
8719            crate::db::init_database(&npub).unwrap();
8720        }
8721        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
8722        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
8723        let client = crate::nostr_client_builder().build();
8724        client.add_managed_relay(relay.as_str()).await.ok();
8725        client.connect().await;
8726        crate::state::set_nostr_client(client);
8727        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
8728        let become_acct = |k: &Keys| {
8729            let npub = k.public_key().to_bech32().unwrap();
8730            crate::db::set_current_account(npub.clone()).unwrap();
8731            crate::db::init_database(&npub).unwrap();
8732            crate::db::clear_id_caches();
8733            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
8734            crate::state::set_my_public_key(k.public_key());
8735        };
8736        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
8737
8738        // A: create + a channel + grant B admin + mint link.
8739        become_acct(&a);
8740        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
8741        let general = community.channels[0].id;
8742        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
8743        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
8744        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
8745        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8746        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
8747        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
8748        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
8749        log(format!("[invite] B granted @admin · link {}", link.url));
8750        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
8751        settle().await;
8752
8753        // B: join + read A's history + reply.
8754        become_acct(&b);
8755        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
8756        log(format!("[join] B joined; {} channels", b_view.channels.len()));
8757        settle().await;
8758        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
8759        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
8760        log(format!("[read] B sees #general: {seen:?}"));
8761        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
8762        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
8763
8764        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
8765        // sends) directly onto the chat plane — proving the cross-client thread
8766        // RECEIVE path works live, not just in the offline fixture.
8767        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
8768        let hello_id = hello.event.opened().rumor_id.to_hex();
8769        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8770        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
8771        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());
8772        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
8773        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
8774        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
8775        settle().await;
8776
8777        // A reads the thread reply back, rendered inline with A's message as parent.
8778        become_acct(&a);
8779        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8780        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
8781        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
8782        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
8783            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
8784            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");
8785        } else {
8786            panic!("the 1111 parsed as a Message");
8787        }
8788        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
8789        become_acct(&b);
8790        settle().await;
8791
8792        // A: create a PRIVATE channel while B is already a member — B is a recipient
8793        // of the creation delivery, so B keys up from the rekey plane over the real
8794        // relay (no bundle involved), then the two converse on it.
8795        become_acct(&a);
8796        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8797        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
8798        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8799        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
8800        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
8801        settle().await;
8802
8803        become_acct(&b);
8804        let session_b = SessionGuard::capture();
8805        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
8806        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
8807            b_view = fresh;
8808        }
8809        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
8810            b_view = fresh;
8811        }
8812        let vch = b_view.channel(&vault).expect("B folded the vault");
8813        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
8814        let vseen = texts_in(&transport, &b_view, &vault).await;
8815        log(format!("[read] B sees #vault: {vseen:?}"));
8816        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
8817        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
8818        settle().await;
8819
8820        become_acct(&a);
8821        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8822        assert!(
8823            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
8824            "A reads B's private reply"
8825        );
8826        log("[private] two-way #vault conversation over the live relay".to_string());
8827
8828        // A: ban B (three-removal) + dissolve.
8829        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
8830        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
8831        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
8832        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
8833        settle().await;
8834        dissolve_community(&transport, &refounded).await.expect("dissolve");
8835        log("[dissolve] community sealed".to_string());
8836        log("===== LIVE e2e PASS =====".to_string());
8837    }
8838
8839    #[tokio::test]
8840    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
8841        // The tombstone rides its own public plane, watched live — an OFFLINE
8842        // member's catch-up must fetch it too, or they follow (and post into) a
8843        // grave forever.
8844        let (bed, owner, member) = TestBed::new();
8845        bed.swap_to(&owner);
8846        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8847        let general = community.channels[0].id;
8848        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8849
8850        bed.swap_to(&member);
8851        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8852        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
8853
8854        // The owner dissolves while the member sleeps.
8855        bed.swap_to(&owner);
8856        dissolve_community(&bed.relay, &community).await.unwrap();
8857
8858        // The member's catch-up learns of the death, seals, and refuses to post.
8859        bed.swap_to(&member);
8860        let session = SessionGuard::capture();
8861        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8862        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
8863        assert!(!follow.self_removed && follow.updated.is_none());
8864        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
8865        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
8866        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
8867        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
8868        // Subsequent follows take the local fast path — still dissolved, no churn.
8869        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8870        assert!(again.dissolved && again.updated.is_none());
8871    }
8872
8873    #[tokio::test]
8874    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
8875        // Scale stress: MANY private channels, each rotated on every Refounding.
8876        // A member offline across two refoundings must converge on all of them
8877        // (the per-channel rotation fan in refound + the follow's channel×root×step
8878        // loops stay bounded) with every channel's history readable.
8879        const PRIV_CHANNELS: usize = 6;
8880        let (bed, owner, member) = TestBed::new();
8881        bed.swap_to(&owner);
8882        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
8883        let mut priv_ids = Vec::new();
8884        for i in 0..PRIV_CHANNELS {
8885            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
8886            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8887            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
8888            priv_ids.push(id);
8889        }
8890        // Private channels are readable only by granted role-holders (CORD-03).
8891        for id in &priv_ids {
8892            grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
8893        }
8894        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8895
8896        // Member joins at epoch 0 with all channel keys, then goes offline.
8897        bed.swap_to(&member);
8898        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8899        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
8900
8901        // Two refoundings (each rotates the base + every private channel).
8902        bed.swap_to(&owner);
8903        for epoch in 1..=2u64 {
8904            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8905            assert_eq!(community.root_epoch, Epoch(epoch));
8906            for id in &priv_ids {
8907                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
8908            }
8909        }
8910
8911        // Member returns: bounded follow to quiescence.
8912        bed.swap_to(&member);
8913        let session = SessionGuard::capture();
8914        let mut passes = 0;
8915        loop {
8916            passes += 1;
8917            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
8918            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8919            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
8920            assert!(!rk.self_removed);
8921            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8922            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
8923            if rk.updated.is_none() && ctl.is_none() {
8924                break;
8925            }
8926        }
8927        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8928        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
8929        // Every private channel converged to the owner's current key + reads all epochs.
8930        for id in &priv_ids {
8931            let mine = caught_up.channel(id).expect("channel survived");
8932            let theirs = community.channel(id).unwrap();
8933            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
8934            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
8935            let texts = texts_in(&bed.relay, &caught_up, id).await;
8936            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
8937            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
8938            for epoch in 1..=2u64 {
8939                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
8940            }
8941        }
8942    }
8943
8944    #[tokio::test]
8945    async fn an_offline_member_catches_up_across_three_refoundings() {
8946        // The deep offline-online scenario: a member sleeps through THREE
8947        // Refoundings, per-refound private-channel rotations, a mid-life private
8948        // channel CREATED while they slept, a public channel, a rename, and a
8949        // ban — then returns and converges by follow alone (no rejoin).
8950        use nostr_sdk::prelude::ToBech32;
8951        let (bed, owner, member) = TestBed::new();
8952        bed.swap_to(&owner);
8953        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
8954        let general = community.channels[0].id;
8955        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
8956        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8957        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
8958        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
8959        // Private channels are readable only by granted role-holders (CORD-03).
8960        grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
8961        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
8962
8963        // Member joins at epoch 0, then goes OFFLINE.
8964        bed.swap_to(&member);
8965        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8966        assert_eq!(member_view.root_epoch, Epoch(0));
8967
8968        // While they sleep, the owner reshapes everything across three epochs.
8969        bed.swap_to(&owner);
8970        let stranger = Keys::generate();
8971        for epoch in 1..=3u64 {
8972            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
8973            assert_eq!(community.root_epoch, Epoch(epoch));
8974            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
8975            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
8976        }
8977        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
8978        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8979        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
8980        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8981        // The sleeper is on this channel's access list, so the refoundings that
8982        // follow deliver its key to them (CORD-03).
8983        grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
8984        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8985        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
8986        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
8987        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
8988        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8989
8990        // The member RETURNS: rekey+control follow to quiescence (the worker's
8991        // loop, driven explicitly). Bounded — convergence must be fast.
8992        bed.swap_to(&member);
8993        let session = SessionGuard::capture();
8994        let mut passes = 0;
8995        loop {
8996            passes += 1;
8997            assert!(passes <= 6, "catch-up must converge, not churn");
8998            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
8999            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9000            assert!(!rekeyed.self_removed, "the member was never removed");
9001            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9002            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9003            if rekeyed.updated.is_none() && controlled.is_none() {
9004                break;
9005            }
9006        }
9007        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9008
9009        // Base + name converged.
9010        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9011        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9012        assert_eq!(caught_up.name, "Sleeper Reborn");
9013        // Channels: renamed set incl. the mid-sleep public + private ones.
9014        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9015        let m = caught_up.channel(&mods).expect("mods survived");
9016        let owner_mods = community.channel(&mods).unwrap();
9017        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9018        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9019        let v = caught_up.channel(&vault).expect("vault folded in");
9020        // The sleeper is on vault's access list, but it was created AFTER the last
9021        // refounding — no rotation followed the grant, so no blob was ever
9022        // addressed to them. They hold the channel keyless until the grant's own
9023        // key vend lands (CORD-05 §6), which is what a rekey-only walk cannot do.
9024        assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9025        // Banlist survived the compactions.
9026        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9027        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9028        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9029        // History reads across EVERY epoch (public via base-root archive, private
9030        // via the per-channel archive built during the walk).
9031        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9032        for epoch in 0..=3u64 {
9033            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9034            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9035        }
9036        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9037        for epoch in 0..=3u64 {
9038            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9039            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9040        }
9041        // Keyless (above) means unreadable — a rekey walk cannot substitute for the
9042        // key vend that a grant carries.
9043        assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9044        // And the member can still speak.
9045        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9046        bed.swap_to(&owner);
9047        assert!(
9048            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9049            "the caught-up member converses at the new epoch ({})",
9050            member.keys.public_key().to_bech32().unwrap()
9051        );
9052    }
9053
9054    /// Seal `n` messages onto a community's #general, one per second starting at
9055    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
9056    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9057        let general = community.channels[0].id;
9058        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9059        for i in 0..n {
9060            let at = base_secs + i as u64;
9061            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9062            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9063            relay.publish(&wrap, &community.relays).await.unwrap();
9064        }
9065    }
9066
9067    #[tokio::test]
9068    async fn the_history_walk_pages_past_a_multi_page_burst() {
9069        // A bot offline through 120 messages must catch ALL of them, not the
9070        // newest page — the v1 sync-gap class, closed by until-paging.
9071        let (_tmp, _guard, owner) = init_test_db();
9072        let relay = MemoryRelay::new();
9073        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9074        let general = community.channels[0].id;
9075        flood_general(&relay, &community, &owner, 120, 10_000).await;
9076
9077        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9078        assert_eq!(all.len(), 120, "the walk pages the whole burst");
9079        // Oldest→newest, no duplicates.
9080        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9081        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9082        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9083        let unique: std::collections::HashSet<&String> = contents.iter().collect();
9084        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9085
9086        // The single-page fetch stays a single page.
9087        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9088        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9089        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9090    }
9091
9092    #[tokio::test]
9093    async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
9094        // The back-paging cursor: a walk that starts at an explicit `until`
9095        // returns only what lies at-or-before it, oldest→newest — the relay-side
9096        // half of the SDK's walk-until-dry loop.
9097        let (_tmp, _guard, owner) = init_test_db();
9098        let relay = MemoryRelay::new();
9099        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9100        let general = community.channels[0].id;
9101        flood_general(&relay, &community, &owner, 120, 10_000).await;
9102
9103        let older = fetch_channel_history(
9104            &relay, &community, &general, 50, 8, None, Some(10_059),
9105            crate::community::transport::Evidence::Quorum, |_| true,
9106        )
9107        .await
9108        .unwrap();
9109        let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9110        assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
9111        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9112        assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
9113    }
9114
9115    #[tokio::test]
9116    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9117        let (_tmp, _guard, owner) = init_test_db();
9118        let relay = MemoryRelay::new();
9119        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9120        let general = community.channels[0].id;
9121        flood_general(&relay, &community, &owner, 120, 10_000).await;
9122
9123        // The caller says "I hold everything" after the first page — no deeper fetch.
9124        let mut pages = 0usize;
9125        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
9126            pages += 1;
9127            false
9128        })
9129        .await
9130        .unwrap();
9131        assert_eq!(pages, 1, "the early stop is consulted once");
9132        assert_eq!(got.len(), 50, "only the newest page is fetched");
9133        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9134    }
9135
9136    #[tokio::test]
9137    async fn a_same_second_history_wall_terminates_instead_of_looping() {
9138        // 60 messages in ONE second with a 25-wrap page: a second-granular
9139        // `until` can never page past the wall — the walk must step over it
9140        // (bounded loss, logged) rather than spin.
9141        let (_tmp, _guard, owner) = init_test_db();
9142        let relay = MemoryRelay::new();
9143        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9144        let general = community.channels[0].id;
9145        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9146        for i in 0..60usize {
9147            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9148            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9149            relay.publish(&wrap, &community.relays).await.unwrap();
9150        }
9151        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9152        assert!(got.len() >= 25, "at least the relay page is read");
9153        assert!(got.len() <= 60, "sane bound");
9154        // Termination is the assertion: reaching here means the wall didn't loop.
9155    }
9156
9157    #[tokio::test]
9158    async fn a_grant_revoke_survives_a_withholding_relay() {
9159        // Floor persistence on the delegation plane: after the owner revokes an admin,
9160        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
9161        let (_tmp, _guard, owner) = init_test_db();
9162        let relay = MemoryRelay::new();
9163        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9164        let admin = Keys::generate();
9165        let rid = "d4".repeat(32);
9166        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9167        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9168        let session = SessionGuard::capture();
9169        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
9170        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
9171        follow_control(&relay, &community, &session).await.unwrap();
9172
9173        // A stale relay serves only the grant prefix (v1, the live grant).
9174        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9175        let mut stale = community.clone();
9176        stale.relays = vec!["wss://stale".into()];
9177        let floors = load_floors(&community);
9178        let editions = fetch_control(&relay, &stale).await;
9179        let authority = fold_authority(&stale, &editions, &floors);
9180        assert!(
9181            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9182            "the persisted grant floor refuses the rolled-back (re-granted) view"
9183        );
9184    }
9185
9186    /// Load the current-epoch floors for a community (test mirror of follow_control).
9187    fn load_floors(community: &CommunityV2) -> Floors {
9188        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9189        crate::db::community::get_all_edition_heads_full(&cid_hex)
9190            .unwrap_or_default()
9191            .into_iter()
9192            .filter(|(_, f)| f.0 == community.root_epoch.0)
9193            .map(|(e, f)| (e, (f.1, f.2, f.3)))
9194            .collect()
9195    }
9196
9197    /// Fetch + open every control edition at a community's control plane (test helper).
9198    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9199        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9200        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9201        relay
9202            .fetch(&q, &community.relays)
9203            .await
9204            .unwrap_or_default()
9205            .iter()
9206            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9207            .collect()
9208    }
9209
9210    #[tokio::test]
9211    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9212        let (_tmp, _guard, _owner) = init_test_db();
9213        let relay = MemoryRelay::new();
9214        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9215        let session = SessionGuard::capture();
9216        // Only the genesis editions exist; folding them reproduces the held view.
9217        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9218    }
9219
9220    #[tokio::test]
9221    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
9222        let (_tmp, _guard, owner) = init_test_db();
9223        let relay = MemoryRelay::new();
9224        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
9225        let new_id = ChannelId([0x5a; 32]);
9226        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
9227
9228        let session = SessionGuard::capture();
9229        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
9230        assert_eq!(updated.channels.len(), 2);
9231        let added = updated.channel(&new_id).expect("the new channel folded in");
9232        assert_eq!(added.name, "announcements");
9233        assert!(!added.private);
9234        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
9235
9236        // The new channel is now in the realtime author-set (it would be subscribed).
9237        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
9238        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
9239        assert!(authors.contains(&addr), "the added channel joins the live subscription");
9240
9241        // Persisted: a reload sees it too.
9242        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9243        assert!(reloaded.channel(&new_id).is_some());
9244    }
9245
9246    #[tokio::test]
9247    async fn follow_control_renames_the_community_and_an_existing_channel() {
9248        let (_tmp, _guard, owner) = init_test_db();
9249        let relay = MemoryRelay::new();
9250        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
9251        let general = community.channels[0].id;
9252        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
9253        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
9254        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
9255
9256        let session = SessionGuard::capture();
9257        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9258        assert_eq!(updated.name, "New Name");
9259        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
9260        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
9261    }
9262
9263    #[tokio::test]
9264    async fn follow_control_deletes_a_channel() {
9265        let (_tmp, _guard, owner) = init_test_db();
9266        let relay = MemoryRelay::new();
9267        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9268        let extra = ChannelId([0x77; 32]);
9269        let session = SessionGuard::capture();
9270
9271        // The channel is first added and folded into the held view.
9272        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9273        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9274        assert!(with_extra.channel(&extra).is_some());
9275
9276        // Then it's tombstoned — the delete (higher version) folds the held one back out.
9277        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9278        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9279        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
9280        assert_eq!(updated.channels.len(), 1, "only #general remains");
9281    }
9282
9283    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
9284    /// community's control plane onto a second relay URL — the withholding-relay
9285    /// simulation: everything it serves is genuinely owner-signed, just stale.
9286    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
9287        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9288        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9289        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9290        for w in &wraps {
9291            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9292                if ed.version <= max_version {
9293                    relay.inject(w, &[stale_relay.to_string()]);
9294                }
9295            }
9296        }
9297    }
9298
9299    #[tokio::test]
9300    async fn a_withholding_relay_cannot_roll_back_a_rename() {
9301        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
9302        // only the (owner-signed) v1 genesis must not revert the held name.
9303        let (_tmp, _guard, owner) = init_test_db();
9304        let relay = MemoryRelay::new();
9305        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
9306        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
9307
9308        let session = SessionGuard::capture();
9309        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
9310        assert_eq!(updated.name, "Renamed");
9311
9312        // The stale relay holds only the genesis prefix; point the follow at it.
9313        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9314        let mut stale_view = updated.clone();
9315        stale_view.relays = vec!["wss://stale".into()];
9316        assert!(
9317            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9318            "a stale-only relay must not change the held view"
9319        );
9320        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9321        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
9322    }
9323
9324    #[tokio::test]
9325    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
9326        let (_tmp, _guard, owner) = init_test_db();
9327        let relay = MemoryRelay::new();
9328        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
9329        let extra = ChannelId([0x44; 32]);
9330        let session = SessionGuard::capture();
9331
9332        // A same-content metadata edit: no visible change (None), but the floor must
9333        // still advance to v2 (so the genesis metadata can't re-present below).
9334        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
9335        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9336
9337        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9338        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9339        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9340        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9341        assert!(pruned.channel(&extra).is_none());
9342
9343        // The stale relay serves the add (v1) but withholds the delete (v2).
9344        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9345        let mut stale_view = pruned.clone();
9346        stale_view.relays = vec!["wss://stale".into()];
9347        assert!(
9348            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9349            "the withheld delete must not resurrect the channel"
9350        );
9351        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9352        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
9353    }
9354
9355    #[tokio::test]
9356    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
9357        // The Armada-convergence carve-out: a Refounding compacts the chain and
9358        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
9359        // floor must not block it — epoch-filtering makes the entity bootstrap.
9360        let (_tmp, _guard, owner) = init_test_db();
9361        let relay = MemoryRelay::new();
9362        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
9363        let session = SessionGuard::capture();
9364        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9365        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
9366        assert_eq!(updated.name, "Edited");
9367
9368        // Refounding lands (epoch bump saved by the rekey path); the compacted head
9369        // arrives DETACHED (high version, no prev) on the new epoch's plane.
9370        let mut refounded = updated.clone();
9371        refounded.root_epoch = crate::community::Epoch(1);
9372        crate::db::community::save_community_v2(&refounded).unwrap();
9373        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
9374
9375        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
9376        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
9377        // The persisted floor is stamped with the epoch the FOLD ran under.
9378        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9379        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
9380        assert!(
9381            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
9382            "the adopted head carries the fold's epoch + version"
9383        );
9384    }
9385
9386    #[tokio::test]
9387    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
9388        // Two owner-signed editions at the SAME version (publish retry / two owner
9389        // devices): every client must land on the lower-inner-id winner. A hash-strict
9390        // floor would wedge here forever while Armada converges — the floor must
9391        // CONVERGE instead (the v1 decide() rule).
9392        let (_tmp, _guard, owner) = init_test_db();
9393        let relay = MemoryRelay::new();
9394        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
9395        let session = SessionGuard::capture();
9396        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9397        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9398
9399        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9400        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9401        assert_eq!(ours.name, "Ours");
9402
9403        // Our committed v2 edition's tiebreak id.
9404        let our_inner = {
9405            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9406            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
9407            wraps
9408                .iter()
9409                .find_map(|w| {
9410                    control::open_control_edition(w, &group)
9411                        .ok()
9412                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
9413                        .map(|(ed, _)| ed.inner_id)
9414                })
9415                .unwrap()
9416        };
9417
9418        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
9419        // authored timestamp until its inner id is lower).
9420        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9421        let content = serde_json::to_string(&meta).unwrap();
9422        let mut ts = 2_000u64;
9423        let fork_wrap = loop {
9424            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9425            let inner = rumor.id.unwrap().to_bytes();
9426            if inner < our_inner {
9427                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9428            }
9429            ts += 1;
9430        };
9431        relay.publish(&fork_wrap, &community.relays).await.unwrap();
9432
9433        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
9434        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
9435        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9436        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
9437        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
9438    }
9439
9440    #[tokio::test]
9441    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
9442        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
9443        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
9444        // while the detached v4 waits. When v3 lands, the chain heals to v4.
9445        let (_tmp, _guard, owner) = init_test_db();
9446        let relay = MemoryRelay::new();
9447        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
9448        let session = SessionGuard::capture();
9449        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9450
9451        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9452        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9453
9454        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
9455        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9456        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
9457        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
9458        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
9459        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9460        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
9461        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
9462        relay.publish(&w4, &community.relays).await.unwrap();
9463
9464        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
9465        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
9466
9467        relay.publish(&w3, &community.relays).await.unwrap();
9468        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
9469        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
9470    }
9471
9472    #[tokio::test]
9473    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
9474        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
9475        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
9476        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
9477        let (_tmp, _guard, owner) = init_test_db();
9478        let relay = MemoryRelay::new();
9479        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9480        let session = SessionGuard::capture();
9481        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9482
9483        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
9484        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
9485        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
9486        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9487
9488        // Rogue flood occupying the newest window (sealed to the control plane, but
9489        // non-owner — the authority gate drops them; they only crowd the page).
9490        let rogue = Keys::generate();
9491        for i in 0..(FOLLOW_PAGE as u64 - 1) {
9492            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
9493            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
9494            relay.publish(&w, &community.relays).await.unwrap();
9495        }
9496        // v4 chained to the real v3 (crafted directly: the flood also blinds the
9497        // helper's own newest-window head lookup), timestamped newest of all.
9498        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
9499        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
9500        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
9501        relay.publish(&w4, &community.relays).await.unwrap();
9502
9503        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
9504        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
9505    }
9506
9507    #[tokio::test]
9508    async fn a_follow_after_delete_does_not_resurrect_the_community() {
9509        // A leave/delete racing an in-flight follow: the follow must not re-insert
9510        // the community row or floor rows past delete_community's wipe.
9511        let (_tmp, _guard, owner) = init_test_db();
9512        let relay = MemoryRelay::new();
9513        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
9514        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
9515        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9516        crate::db::community::delete_community(&cid_hex).unwrap();
9517
9518        let session = SessionGuard::capture();
9519        assert!(
9520            follow_control(&relay, &community, &session).await.unwrap().is_none(),
9521            "a follow racing a delete is a no-op"
9522        );
9523        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9524        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
9525    }
9526
9527    #[tokio::test]
9528    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
9529        // The rekey sibling of the follow_control guard: an owner rotation adopted
9530        // mid-race must not upsert the community row back after a leave/delete.
9531        let (_tmp, _guard, owner) = init_test_db();
9532        let relay = MemoryRelay::new();
9533        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
9534        let new_root = [0xB2; 32];
9535        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
9536        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9537        crate::db::community::delete_community(&cid_hex).unwrap();
9538
9539        let session = SessionGuard::capture();
9540        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
9541        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
9542        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
9543    }
9544
9545    #[tokio::test]
9546    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
9547        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
9548        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
9549        // would take v1 and SEED the joiner's floor there — pinning them below the
9550        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
9551        let (bed, owner, member) = TestBed::new();
9552        bed.swap_to(&owner);
9553        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
9554        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9555        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
9556
9557        // v2 is crafted but NEVER published; v3 chains to it and is published.
9558        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
9559        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
9560        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
9561        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
9562        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
9563        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);
9564        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
9565        bed.relay.publish(&w3, &community.relays).await.unwrap();
9566
9567        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9568        let bundle_json = serde_json::to_string(&bundle).unwrap();
9569        bed.swap_to(&member);
9570        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9571        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
9572        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9573        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
9574        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
9575    }
9576
9577    #[tokio::test]
9578    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
9579        // The refusal half of fork convergence: a relay withholding OUR committed
9580        // floor edition while serving only a same-version fork with a HIGHER inner
9581        // id must be treated as withholding — held state and floor unchanged.
9582        let (_tmp, _guard, owner) = init_test_db();
9583        let relay = MemoryRelay::new();
9584        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
9585        let session = SessionGuard::capture();
9586        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9587        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
9588
9589        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
9590        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
9591        assert_eq!(ours.name, "Ours");
9592        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9593        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9594        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
9595
9596        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
9597        // with the genesis but WITHOUT our v2 — from a withholding relay.
9598        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
9599        let content = serde_json::to_string(&meta).unwrap();
9600        let mut ts = 5_000u64;
9601        let fork_wrap = loop {
9602            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
9603            if rumor.id.unwrap().to_bytes() > our_inner {
9604                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
9605            }
9606            ts += 1;
9607        };
9608        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
9609        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
9610        let mut stale_view = ours.clone();
9611        stale_view.relays = vec!["wss://stale".into()];
9612
9613        assert!(
9614            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9615            "a losing fork served without our floor edition changes nothing"
9616        );
9617        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
9618        assert_eq!(held_after, held_before, "the floor row is untouched");
9619        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9620        assert_eq!(held.name, "Ours", "the held state is untouched");
9621    }
9622
9623    #[tokio::test]
9624    async fn follow_control_ignores_a_non_owner_edition() {
9625        // A member holds the community_root, so they CAN seal a control edition —
9626        // but they aren't the owner, so the authority gate drops it (first cut:
9627        // owner-only). The rogue channel must never appear.
9628        let (_tmp, _guard, _owner) = init_test_db();
9629        let relay = MemoryRelay::new();
9630        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
9631        let rogue = Keys::generate();
9632        let rogue_id = ChannelId([0x99; 32]);
9633        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
9634
9635        let session = SessionGuard::capture();
9636        assert!(
9637            follow_control(&relay, &community, &session).await.unwrap().is_none(),
9638            "a non-owner control edition is not folded"
9639        );
9640    }
9641
9642    #[tokio::test]
9643    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
9644        // A Private channel's key rides the rekey plane, not the control edition —
9645        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
9646        // every read/send path refuses it until the key lands (never the root plane).
9647        let (_tmp, _guard, owner) = init_test_db();
9648        let relay = MemoryRelay::new();
9649        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
9650        let priv_id = ChannelId([0x33; 32]);
9651        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
9652
9653        let session = SessionGuard::capture();
9654        let updated = follow_control(&relay, &community, &session)
9655            .await
9656            .unwrap()
9657            .expect("the keyless record is a change");
9658        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
9659        assert!(ch.private && ch.key.is_none(), "recorded keyless");
9660        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
9661        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
9662        assert!(
9663            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
9664            "a keyless fetch returns empty (and never queries the root plane)"
9665        );
9666        assert!(
9667            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
9668            "a keyless send refuses"
9669        );
9670        // The keyless record round-trips (the stored placeholder never surfaces
9671        // as a real key).
9672        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
9673        let rch = reloaded.channel(&priv_id).unwrap();
9674        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
9675        // And a bundle minted while keyless never carries the placeholder — a
9676        // MEMBER audience, so it's the keyless filter proving it (the link
9677        // filter would drop the channel for the weaker reason).
9678        let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9679        assert!(
9680            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
9681            "an ungrantable keyless channel stays out of invite bundles"
9682        );
9683    }
9684
9685    #[tokio::test]
9686    async fn a_link_bundle_never_carries_a_private_channel_key() {
9687        // A link's audience holds no Role by construction (CORD-05), so a HELD
9688        // private key must never ride a link bundle — anyone with the URL would
9689        // get the channel. A member bundle carries it; a link bundle only the
9690        // public channels.
9691        let (_tmp, _guard, _owner) = init_test_db();
9692        let relay = MemoryRelay::new();
9693        let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
9694        create_private_channel(&relay, &community, "mods").await.unwrap();
9695        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9696        let priv_hex = held
9697            .channels
9698            .iter()
9699            .find(|c| c.private)
9700            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
9701            .expect("the private channel is held WITH its key");
9702
9703        let link = bundle_of(&held, BundleAudience::Link, None, None, None);
9704        assert!(
9705            !link.channels.iter().any(|c| c.id == priv_hex),
9706            "a held private key must never ride a link bundle"
9707        );
9708        assert!(
9709            link.channels.iter().any(|c| c.id != priv_hex),
9710            "the public channels still ride it"
9711        );
9712
9713        // A member bundle grants it only to the ENTITLED. An unrelated npub holds
9714        // no scoped role, so it gets nothing; the creator (granted the companion
9715        // access role at create) gets the key.
9716        let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
9717        assert!(
9718            !stranger.channels.iter().any(|c| c.id == priv_hex),
9719            "an unentitled member gets no private key"
9720        );
9721        let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
9722        assert!(
9723            mine.channels.iter().any(|c| c.id == priv_hex),
9724            "the creator is entitled via the companion access role"
9725        );
9726    }
9727
9728    #[tokio::test]
9729    async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
9730        // CORD-03/04: the roles scoped to a channel ARE its access list. Proven
9731        // against a NON-owner so the owner-is-always-entitled rule can't carry it.
9732        let (_tmp, _guard, _owner) = init_test_db();
9733        let relay = MemoryRelay::new();
9734        let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
9735        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
9736        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9737        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9738
9739        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
9740        let access = roster.channel_roles(&chan_hex);
9741        assert_eq!(access.len(), 1, "the channel minted exactly one access role");
9742        assert!(
9743            access[0].permissions == crate::community::roles::Permissions::empty(),
9744            "the access role confers READ access (key possession), never authority"
9745        );
9746        assert_eq!(access[0].name, "mods", "named for its channel");
9747
9748        // A stranger holds no scoped role: unentitled, and no key rides their bundle.
9749        let stranger = Keys::generate().public_key();
9750        let owner_hex = community.owner().unwrap().to_hex();
9751        assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
9752
9753        // Granting the access role entitles them; revoking un-entitles them. Both
9754        // proven through the roster, which is what routes keys.
9755        let role_id = access[0].role_id.clone();
9756        assert!(
9757            roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
9758            "the grant overlay entitles before the fold catches up"
9759        );
9760
9761        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9762        grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9763        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9764        assert!(
9765            after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9766            "the grant landed in the local roster (the fold runs later)"
9767        );
9768        let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
9769        assert!(
9770            vend.channels.iter().any(|c| c.id == chan_hex),
9771            "a now-entitled member's bundle carries the channel key"
9772        );
9773
9774        revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
9775        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
9776        assert!(
9777            !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
9778            "the revoke dropped the access role"
9779        );
9780        let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9781        assert_eq!(
9782            rotated.channel(&priv_id).unwrap().epoch,
9783            Epoch(2),
9784            "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
9785        );
9786
9787        // The access summary a bot reads back: roles, holders, and key state.
9788        let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
9789        assert_eq!(access["private"], true);
9790        assert_eq!(access["readable"], true, "we minted it, so we hold its key");
9791        assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
9792        let holders = access["members"].as_array().unwrap();
9793        let me_npub = {
9794            use nostr_sdk::prelude::ToBech32;
9795            me_pk().unwrap().to_bech32().unwrap()
9796        };
9797        assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
9798        assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
9799    }
9800
9801    #[tokio::test]
9802    async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
9803        // JSKitty's race: the vend can land BEFORE the control fold that proves
9804        // the grant. It must park quietly (a lagging fold is not an anomaly) and
9805        // be adopted on the re-judge once the roster catches up.
9806        let (bed, owner, member) = TestBed::new();
9807        bed.swap_to(&owner);
9808        let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
9809        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9810        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9811        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9812        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9813        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
9814        let owner_hex = community.owner().unwrap().to_hex();
9815
9816        // Judge as the MEMBER — the owner is always entitled, so only a non-owner
9817        // can exercise the grant rule at all.
9818        bed.swap_to(&member);
9819        let me = member.keys.public_key().to_hex();
9820        // Their fold has the channel (control-follow records it keyless) but not
9821        // yet the grant that entitles them.
9822        let mut member_view = held.clone();
9823        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9824            c.key = None;
9825            c.epoch = Epoch(0);
9826        }
9827
9828        // Ungranted → PARK, never refuse: this is exactly the "not synced enough
9829        // to judge" case, and it must stay quiet and retryable.
9830        let empty = crate::community::roles::CommunityRoles::default();
9831        assert!(matches!(
9832            judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
9833            VendVerdict::Park(_)
9834        ));
9835
9836        // A channel our fold says is PUBLIC never heals — that's a spoof shape.
9837        let mut public_view = member_view.clone();
9838        if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9839            c.private = false;
9840        }
9841        assert!(matches!(
9842            judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
9843            VendVerdict::Refuse(_)
9844        ));
9845
9846        // An unknown channel parks (our fold may simply be behind), never refuses.
9847        assert!(matches!(
9848            judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
9849            VendVerdict::Park(_)
9850        ));
9851
9852        // Park the vend, then re-judge with a roster that still lacks our grant:
9853        // it must SURVIVE, not be discarded.
9854        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
9855        crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
9856        crate::db::community::save_community_v2(&member_view).unwrap();
9857        let session = SessionGuard::capture();
9858        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9859        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
9860        assert_eq!(
9861            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
9862            1,
9863            "and stays parked for the next fold"
9864        );
9865
9866        // The fold catches up: our grant lands, so the same vend now adopts.
9867        let access = crate::community::roles::Role {
9868            role_id: "44".repeat(32),
9869            name: "mods".into(),
9870            position: u32::MAX - 1,
9871            permissions: crate::community::roles::Permissions::empty(),
9872            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9873            color: 0,
9874        };
9875        let folded = crate::community::roles::CommunityRoles {
9876            grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
9877            roles: vec![access],
9878        };
9879        crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
9880        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9881        let adopted = absorb_parked_channel_keys(&reloaded, &session);
9882        assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
9883
9884        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9885        let ch = after.channel(&priv_id).unwrap();
9886        assert_eq!(ch.key, Some(real_key), "adopted the vended key");
9887        assert_eq!(ch.epoch, Epoch(1));
9888        assert!(
9889            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9890            "and the park is discharged"
9891        );
9892    }
9893
9894    #[tokio::test]
9895    async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
9896        // Live cross-client finding: a peer that mints born-private channels at
9897        // epoch 0 vends epoch 0, which collides with our keyless cursor (also 0).
9898        // The monotonic guard (`new > current`) would refuse the only key we are
9899        // ever offered, and refuse it SILENTLY. First delivery is not a rotation.
9900        let (bed, owner, member) = TestBed::new();
9901        bed.swap_to(&owner);
9902        let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
9903        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9904        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9905        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9906        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9907        let vended = [0x5a; 32];
9908        let owner_hex = community.owner().unwrap().to_hex();
9909
9910        bed.swap_to(&member);
9911        // The member's view: channel known, keyless, parked at the epoch-0 cursor.
9912        let mut member_view = held.clone();
9913        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9914            c.key = None;
9915            c.epoch = Epoch(0);
9916        }
9917        crate::db::community::save_community_v2(&member_view).unwrap();
9918
9919        // Entitle them, then park a vend AT EPOCH 0 (what the peer actually sends).
9920        let access = crate::community::roles::Role {
9921            role_id: "77".repeat(32),
9922            name: "mods".into(),
9923            position: u32::MAX - 1,
9924            permissions: crate::community::roles::Permissions::empty(),
9925            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9926            color: 0,
9927        };
9928        let roster = crate::community::roles::CommunityRoles {
9929            grants: vec![crate::community::roles::MemberGrant {
9930                member: member.keys.public_key().to_hex(),
9931                role_ids: vec![access.role_id.clone()],
9932            }],
9933            roles: vec![access],
9934        };
9935        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9936        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
9937
9938        let session = SessionGuard::capture();
9939        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9940        let adopted = absorb_parked_channel_keys(&reloaded, &session);
9941        assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
9942
9943        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9944        let ch = after.channel(&priv_id).unwrap();
9945        assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
9946        assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
9947        assert!(
9948            !after.channel_read_coords(ch).is_empty(),
9949            "and the channel is readable — the whole point"
9950        );
9951        assert!(
9952            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
9953            "the park is discharged"
9954        );
9955    }
9956
9957    #[tokio::test]
9958    async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
9959        // The channel head is MONOTONIC, so over-advancing it can never be walked
9960        // back: every genuine rotation afterwards lands at head+1, reads as stale,
9961        // and the channel dies for us with no heal path at all. An entitled
9962        // insider vending a garbage key costs isolation (accepted); one vending a
9963        // garbage EPOCH would cost the channel permanently, which is not.
9964        let (bed, owner, member) = TestBed::new();
9965        bed.swap_to(&owner);
9966        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
9967        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9968        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9969        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
9970        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9971        let owner_hex = community.owner().unwrap().to_hex();
9972
9973        bed.swap_to(&member);
9974        let mut member_view = held.clone();
9975        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
9976            c.key = None;
9977            c.epoch = Epoch(0);
9978        }
9979        crate::db::community::save_community_v2(&member_view).unwrap();
9980        let access = crate::community::roles::Role {
9981            role_id: "99".repeat(32),
9982            name: "mods".into(),
9983            position: u32::MAX - 1,
9984            permissions: crate::community::roles::Permissions::empty(),
9985            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
9986            color: 0,
9987        };
9988        let roster = crate::community::roles::CommunityRoles {
9989            grants: vec![crate::community::roles::MemberGrant {
9990                member: member.keys.public_key().to_hex(),
9991                role_ids: vec![access.role_id.clone()],
9992            }],
9993            roles: vec![access],
9994        };
9995        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
9996        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9997
9998        // Everything else about this vend is valid — only the epoch is absurd.
9999        assert!(matches!(
10000            judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
10001            VendVerdict::Refuse(_)
10002        ));
10003        // REFUSED, not parked: a row nothing can ever discharge is its own leak.
10004        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
10005        let session = SessionGuard::capture();
10006        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
10007        assert!(
10008            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10009            "and the row is discharged rather than parked forever"
10010        );
10011        // The head is untouched, so the genuine vend still lands afterwards.
10012        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10013        assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
10014        assert!(matches!(
10015            judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
10016            VendVerdict::Accept
10017        ));
10018    }
10019
10020    #[tokio::test]
10021    async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
10022        // The fold is the authority but runs later, so publishing alone leaves the
10023        // edit reading back stale — it looks like the rename silently failed.
10024        let (_tmp, _guard, _owner) = init_test_db();
10025        let relay = MemoryRelay::new();
10026        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10027        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10028        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10029        let key_before = held.channel(&priv_id).unwrap().key;
10030
10031        let mut meta = held.channel(&priv_id).unwrap().metadata();
10032        meta.name = "staff".into();
10033        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10034
10035        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10036        let ch = after.channel(&priv_id).unwrap();
10037        assert_eq!(ch.name, "staff", "the rename is visible immediately");
10038        assert!(ch.private, "and privacy survives the edit");
10039        assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10040    }
10041
10042    #[tokio::test]
10043    async fn a_channel_rename_carries_its_companion_access_role() {
10044        let (_tmp, _guard, _owner) = init_test_db();
10045        let relay = MemoryRelay::new();
10046        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10047        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10048        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10049        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10050
10051        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10052        let before = roster.channel_roles(&chan_hex);
10053        assert_eq!(before.len(), 1, "one companion role, minted at create");
10054        assert_eq!(before[0].name, "mods", "named after the channel it gates");
10055        let role_id = before[0].role_id.clone();
10056
10057        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10058        let mut meta = held.channel(&priv_id).unwrap().metadata();
10059        meta.name = "staff".into();
10060        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10061
10062        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10063        let after = roster.channel_roles(&chan_hex);
10064        assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10065        assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10066        assert_eq!(after[0].name, "staff", "the access role followed the channel");
10067        assert_eq!(
10068            after[0].permissions,
10069            crate::community::roles::Permissions::empty(),
10070            "and still confers read access, never authority"
10071        );
10072    }
10073
10074    #[tokio::test]
10075    async fn a_customised_access_role_name_survives_a_channel_rename() {
10076        // The label is cosmetic — entitlement rides the scope. Overwriting a name
10077        // someone chose deliberately is the surprising half of "keep them in step".
10078        let (_tmp, _guard, _owner) = init_test_db();
10079        let relay = MemoryRelay::new();
10080        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10081        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10082        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10083        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10084
10085        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10086        let mut role = roster.channel_roles(&chan_hex)[0].clone();
10087        role.name = "Lab Insiders".into();
10088        set_role(&relay, &community, &role).await.unwrap();
10089        merge_local_roster(&cid_hex, Some(&role), None);
10090
10091        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10092        let mut meta = held.channel(&priv_id).unwrap().metadata();
10093        meta.name = "staff".into();
10094        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10095
10096        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10097        assert_eq!(
10098            roster.channel_roles(&chan_hex)[0].name,
10099            "Lab Insiders",
10100            "a deliberate name is left alone"
10101        );
10102    }
10103
10104    #[tokio::test]
10105    async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
10106        // Parking is reachable by ANY npub that can gift-wrap us — the bundle
10107        // self-certifies and its inputs are public for a public community. With a
10108        // single slot per channel, a stranger could pre-park and the admin's real
10109        // vend would be a silent no-op, leaving the member keyless with no retry.
10110        // Candidates + judge-them-all is what closes that.
10111        let (bed, owner, member) = TestBed::new();
10112        bed.swap_to(&owner);
10113        let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
10114        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10115        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10116        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10117        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10118        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10119        let owner_hex = community.owner().unwrap().to_hex();
10120
10121        bed.swap_to(&member);
10122        let mut member_view = held.clone();
10123        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10124            c.key = None;
10125            c.epoch = Epoch(0);
10126        }
10127        crate::db::community::save_community_v2(&member_view).unwrap();
10128        let access = crate::community::roles::Role {
10129            role_id: "aa".repeat(32),
10130            name: "mods".into(),
10131            position: u32::MAX - 1,
10132            permissions: crate::community::roles::Permissions::empty(),
10133            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10134            color: 0,
10135        };
10136        let roster = crate::community::roles::CommunityRoles {
10137            grants: vec![crate::community::roles::MemberGrant {
10138                member: member.keys.public_key().to_hex(),
10139                role_ids: vec![access.role_id.clone()],
10140            }],
10141            roles: vec![access],
10142        };
10143        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10144
10145        // A stranger squats FIRST, at a higher epoch than the genuine vend.
10146        let stranger = Keys::generate().public_key().to_hex();
10147        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
10148        // The admin's real vend arrives after, at the true epoch.
10149        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10150        assert_eq!(
10151            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10152            2,
10153            "the squatter never displaces the genuine vend — both are candidates"
10154        );
10155
10156        let session = SessionGuard::capture();
10157        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10158        let adopted = absorb_parked_channel_keys(&reloaded, &session);
10159        assert_eq!(adopted.len(), 1, "exactly one adoption");
10160
10161        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10162        let ch = after.channel(&priv_id).unwrap();
10163        assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
10164        assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
10165        assert!(
10166            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10167            "and every candidate for the channel is discharged"
10168        );
10169    }
10170
10171    #[tokio::test]
10172    async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
10173        // With no access role folded, the retained-set filter matches NOBODY, so
10174        // the rotation would cut off every legitimately entitled member while the
10175        // Grant it published revoked nothing. Reachable with no attacker: the
10176        // channel was made on another admin's client and its role hasn't folded.
10177        let (_tmp, _guard, _owner) = init_test_db();
10178        let relay = MemoryRelay::new();
10179        let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
10180        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10181        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10182        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10183        let before = held.channel(&priv_id).unwrap().epoch;
10184
10185        // Neither the cache nor the plane serves the access role — a withholding
10186        // relay, or a channel minted on another admin's client. (Wiping only the
10187        // cache is no longer enough: the revoke re-fetches authority first.)
10188        crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
10189        let mut blind = held.clone();
10190        blind.relays = vec!["wss://empty".into()];
10191        let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
10192            .await
10193            .unwrap_err();
10194        assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
10195
10196        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10197        assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
10198    }
10199
10200    // ── Live rekey-follow ────────────────────────────────────────────────────
10201
10202    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
10203    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
10204    /// a stranger for the authority test); `prev_key` is the root it claims to
10205    /// extend (mismatch → a fork).
10206    async fn publish_base_rotation(
10207        relay: &MemoryRelay,
10208        community: &CommunityV2,
10209        rotator: &Keys,
10210        recipients: &[PublicKey],
10211        new_root: &[u8; 32],
10212        prev_key: &[u8; 32],
10213    ) {
10214        let new_epoch = Epoch(community.root_epoch.0 + 1);
10215        let prev_epoch = community.root_epoch;
10216        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
10217        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10218        let blobs: Vec<_> = recipients
10219            .iter()
10220            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
10221            .collect();
10222        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();
10223        for e in &events {
10224            relay.publish(e, &community.relays).await.unwrap();
10225        }
10226    }
10227
10228    /// Attach a Private channel (key + epoch) to a held community and persist it.
10229    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
10230        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
10231        crate::db::community::save_community_v2(community).unwrap();
10232    }
10233
10234    #[tokio::test]
10235    async fn follow_rekeys_is_a_noop_without_rotations() {
10236        let (_tmp, _guard, _owner) = init_test_db();
10237        let relay = MemoryRelay::new();
10238        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
10239        let session = SessionGuard::capture();
10240        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10241        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
10242    }
10243
10244    #[tokio::test]
10245    async fn follow_rekeys_adopts_an_owner_base_rotation() {
10246        let (_tmp, _guard, owner) = init_test_db();
10247        let relay = MemoryRelay::new();
10248        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
10249        let new_root = [0xB1; 32];
10250        // Owner rotates the base to epoch 1, delivering the new root to me.
10251        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10252
10253        let session = SessionGuard::capture();
10254        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10255        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
10256        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
10257        // The public channel now reads under the NEW root/epoch (its address moved).
10258        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10259        let general = updated.channels[0].id;
10260        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
10261        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
10262    }
10263
10264    #[tokio::test]
10265    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
10266        let (_tmp, _guard, owner) = init_test_db();
10267        let relay = MemoryRelay::new();
10268        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
10269        let priv_id = ChannelId([0x33; 32]);
10270        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
10271
10272        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
10273        let new_key = [0x55; 32];
10274        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
10275        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10276        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();
10277        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
10278        for e in &events {
10279            relay.publish(e, &community.relays).await.unwrap();
10280        }
10281
10282        let session = SessionGuard::capture();
10283        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10284        let ch = updated.channel(&priv_id).unwrap();
10285        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
10286        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
10287        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
10288    }
10289
10290    #[tokio::test]
10291    async fn follow_rekeys_ignores_a_non_owner_rotation() {
10292        // A member holds the community_root, so they can derive the rekey group key
10293        // and mint a rotation — but they aren't the owner, so it's not adopted.
10294        let (_tmp, _guard, _owner) = init_test_db();
10295        let relay = MemoryRelay::new();
10296        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10297        let rogue = Keys::generate();
10298        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
10299
10300        let session = SessionGuard::capture();
10301        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10302        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
10303    }
10304
10305    #[tokio::test]
10306    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
10307        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
10308        // extension — never adopted (would splice me onto an unrelated chain).
10309        let (_tmp, _guard, owner) = init_test_db();
10310        let relay = MemoryRelay::new();
10311        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10312        // prev_key ≠ the real community_root → the continuity check reads Fork.
10313        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
10314
10315        let session = SessionGuard::capture();
10316        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10317        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
10318    }
10319
10320    #[tokio::test]
10321    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
10322        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
10323        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
10324        let (_tmp, _guard, owner) = init_test_db();
10325        let relay = MemoryRelay::new();
10326        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
10327        let new_epoch = Epoch(1);
10328        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10329        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10330        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
10331        let other = Keys::generate();
10332        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
10333        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
10334        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
10335        relay.publish(&wrap, &community.relays).await.unwrap();
10336
10337        let session = SessionGuard::capture();
10338        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10339        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
10340    }
10341
10342    #[tokio::test]
10343    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
10344        // Realistic two-actor removal: the owner Refounds the base and delivers the
10345        // new root to a THIRD party, not the member — a complete rotation with no
10346        // blob for the member is a removal.
10347        let (bed, owner, member) = TestBed::new();
10348        bed.swap_to(&owner);
10349        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
10350        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10351
10352        bed.swap_to(&member);
10353        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10354        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10355
10356        // Owner rotates, delivering only to a stranger (the member is dropped).
10357        bed.swap_to(&owner);
10358        let stranger = Keys::generate();
10359        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
10360
10361        // The member's follow concludes removal (a complete rotation without their blob).
10362        bed.swap_to(&member);
10363        let session = SessionGuard::capture();
10364        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
10365        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
10366        assert!(follow.updated.is_none(), "a removed member adopts nothing");
10367    }
10368
10369    #[tokio::test]
10370    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
10371        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
10372        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
10373        // the base crate landed and was walked before the channel crates) must
10374        // still find them — the lookup fans across the archived roots, not just
10375        // the current one.
10376        let (_tmp, _guard, owner) = init_test_db();
10377        let relay = MemoryRelay::new();
10378        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
10379        let root0 = community.community_root;
10380        let priv_id = ChannelId([0x33; 32]);
10381        let key1 = [0x44; 32];
10382        add_private_channel(&mut community, priv_id, key1, Epoch(1));
10383
10384        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
10385        // root (root0), delivering the fresh key to me.
10386        let key2 = [0x55; 32];
10387        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10388        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
10389        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();
10390        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() {
10391            relay.publish(&e, &community.relays).await.unwrap();
10392        }
10393
10394        // Simulate the base having ALREADY advanced (the stranding order): the head
10395        // moved to a fresh root while root0 sits in the epoch-key archive (where
10396        // genesis put it).
10397        community.community_root = [0xB7; 32];
10398        community.root_epoch = Epoch(1);
10399        crate::db::community::save_community_v2(&community).unwrap();
10400
10401        let session = SessionGuard::capture();
10402        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
10403        let ch = updated.channel(&priv_id).unwrap();
10404        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
10405        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
10406    }
10407
10408    #[tokio::test]
10409    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
10410        // A keyless private channel (announced by vsk-2, key not yet held) has no
10411        // chain, so its epoch is a scan cursor: a complete rotation that excludes
10412        // us advances the cursor (never a removal — we were never in); a later
10413        // rotation that includes us is the entry point.
10414        let (_tmp, _guard, owner) = init_test_db();
10415        let relay = MemoryRelay::new();
10416        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
10417        let priv_id = ChannelId([0x66; 32]);
10418        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() });
10419        crate::db::community::save_community_v2(&community).unwrap();
10420        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10421        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
10422
10423        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
10424        let stranger = Keys::generate();
10425        let key1 = [0x71; 32];
10426        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10427        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10428        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();
10429        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
10430            relay.publish(&e, &community.relays).await.unwrap();
10431        }
10432        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
10433        // recipient set is the CURRENT members).
10434        let key2 = [0x72; 32];
10435        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10436        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10437        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();
10438        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
10439            relay.publish(&e, &community.relays).await.unwrap();
10440        }
10441
10442        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
10443        // blob — adopt), because each real step re-loops.
10444        let session = SessionGuard::capture();
10445        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
10446        let ch = updated.channel(&priv_id).unwrap();
10447        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
10448        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
10449    }
10450
10451    #[tokio::test]
10452    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
10453        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
10454        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
10455        // rotation keys members up; a mere keyholder's forgery never does.
10456        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10457        let (_tmp, _guard, _owner) = init_test_db();
10458        let relay = MemoryRelay::new();
10459        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
10460        let priv_id = ChannelId([0x88; 32]);
10461        let key1 = [0x91; 32];
10462        add_private_channel(&mut community, priv_id, key1, Epoch(1));
10463
10464        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
10465        let admin = Keys::generate();
10466        let role = Role::admin("aa".repeat(32));
10467        let roster = CommunityRoles {
10468            roles: vec![role.clone()],
10469            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10470        };
10471        seed_roster_with_heads(&community, &roster, 1_000);
10472
10473        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
10474        let key2 = [0x92; 32];
10475        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10476        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10477        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10478        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10479        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() {
10480            relay.publish(&e, &community.relays).await.unwrap();
10481        }
10482        let session = SessionGuard::capture();
10483        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
10484        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
10485
10486        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
10487        let rogue = Keys::generate();
10488        let key3 = [0x93; 32];
10489        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
10490        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
10491        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
10492        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
10493            relay.publish(&e, &updated.relays).await.unwrap();
10494        }
10495        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
10496        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
10497    }
10498
10499    #[tokio::test]
10500    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
10501        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
10502        // target. An equal-rank bit-holder's complete rotation that skips my blob
10503        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
10504        // two-account bed: the follower must be a NON-owner admin.
10505        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10506        let (bed, owner, member) = TestBed::new();
10507        bed.swap_to(&owner);
10508        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
10509
10510        // The MEMBER's device: holds the community + the private channel, with a
10511        // persisted roster granting the member AND a peer the same Admin role.
10512        bed.swap_to(&member);
10513        let mut held = community.clone();
10514        let priv_id = ChannelId([0xAB; 32]);
10515        let key1 = [0xA1; 32];
10516        add_private_channel(&mut held, priv_id, key1, Epoch(1));
10517        let peer = Keys::generate();
10518        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10519        let role = Role::admin("bb".repeat(32));
10520        let roster = CommunityRoles {
10521            roles: vec![role.clone()],
10522            grants: vec![
10523                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10524                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
10525            ],
10526        };
10527        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
10528
10529        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
10530        let key2 = [0xA2; 32];
10531        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10532        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
10533        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();
10534        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() {
10535            bed.relay.publish(&e, &held.relays).await.unwrap();
10536        }
10537        let session = SessionGuard::capture();
10538        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10539        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
10540        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
10541        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
10542
10543        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
10544        let key3 = [0xA3; 32];
10545        let stranger = Keys::generate();
10546        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();
10547        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() {
10548            bed.relay.publish(&e, &held.relays).await.unwrap();
10549        }
10550        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
10551        let updated = follow.updated.expect("the owner's removal folds");
10552        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
10553    }
10554
10555    #[tokio::test]
10556    async fn converting_a_public_channel_to_private_is_refused() {
10557        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
10558        // the producer refuses the flag flip, so no reader is left unkeyable.
10559        let (_tmp, _guard, _owner) = init_test_db();
10560        let relay = MemoryRelay::new();
10561        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
10562        let general = community.channels[0].id;
10563        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
10564        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
10565        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
10566        // A rename of the same public channel still works.
10567        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
10568        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
10569    }
10570
10571    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
10572    /// `removed_at` — simulating a sibling device having left that community.
10573    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
10574        let doc = super::super::list::CommunityList {
10575            entries: vec![],
10576            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
10577            extra: Default::default(),
10578        };
10579        let event = super::super::list::build_list_event(me, &doc).unwrap();
10580        relay.publish(&event, relays).await.unwrap();
10581    }
10582
10583    #[tokio::test]
10584    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
10585        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
10586        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
10587        // which would silently undo the leave everywhere.
10588        let (_tmp, _guard, me) = init_test_db();
10589        let relay = MemoryRelay::new();
10590        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
10591        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10592
10593        // A sibling leaves X: a remote tombstone strictly newer than X's add.
10594        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10595
10596        // Now join a different community Y → republish(just_joined = Y).
10597        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
10598        republish_community_list(&relay, Some(y.id())).await.unwrap();
10599
10600        // X must still read as LEFT in the published list; Y must be live.
10601        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
10602        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
10603        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
10604    }
10605
10606    #[tokio::test]
10607    async fn sync_tears_down_a_community_a_sibling_left() {
10608        // W1 (receive side): a community still held locally that the synced 13302
10609        // shows tombstoned-and-not-live is torn down, so a leave propagates.
10610        let (_tmp, _guard, me) = init_test_db();
10611        let relay = MemoryRelay::new();
10612        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
10613        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10614        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
10615
10616        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
10617        sync_community_list(&relay, &x.relays).await.unwrap();
10618        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
10619    }
10620
10621    #[tokio::test]
10622    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
10623        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
10624        // the tombstone), so a later sync keeps it.
10625        let (_tmp, _guard, me) = init_test_db();
10626        let relay = MemoryRelay::new();
10627        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
10628        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
10629        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
10630        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
10631        // Re-record the membership (a re-join) → live entry at now >> 1.
10632        republish_community_list(&relay, Some(x.id())).await.unwrap();
10633        sync_community_list(&relay, &x.relays).await.unwrap();
10634        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
10635    }
10636
10637    #[tokio::test]
10638    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
10639        // W2: a transient fetch failure during republish must not drive the
10640        // replaceable-event write (which would drop other entries / regress seeds).
10641        let (_tmp, _guard, _me) = init_test_db();
10642        let good = MemoryRelay::new();
10643        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
10644        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
10645
10646        // A transport whose fetch always errors: republish must bail, publishing nothing.
10647        struct FetchErrors;
10648        #[async_trait::async_trait]
10649        impl Transport for FetchErrors {
10650            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
10651            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10652                panic!("republish must NOT publish when the remote fetch failed");
10653            }
10654            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
10655                Ok(())
10656            }
10657            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
10658                Err("relay unreachable".to_string())
10659            }
10660        }
10661        // Returns Ok (best-effort) but must not have published (the panic guards it).
10662        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
10663    }
10664
10665    #[tokio::test]
10666    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
10667        // B1 regression: refound_community's recipient set = memberlist. A member
10668        // the owner GRANTED a role to but who never left a (surviving) Guestbook
10669        // Join — a lurking admin, or one whose Join aged out of the window — must
10670        // still be a rekey recipient, or the Refounding SEVERS them. The folded
10671        // roster's granted members are the consensus-complete backstop.
10672        let (_tmp, _guard, owner) = init_test_db();
10673        let relay = MemoryRelay::new();
10674        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
10675
10676        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
10677        let lurker = Keys::generate();
10678        let rid = "b1".repeat(32);
10679        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
10680        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
10681
10682        // memberlist includes the lurker purely via the roster backstop.
10683        let members = memberlist(&relay, &community).await.unwrap();
10684        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
10685
10686        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
10687        let banned_grantee = Keys::generate();
10688        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
10689        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
10690        let members = memberlist(&relay, &community).await.unwrap();
10691        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
10692        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
10693
10694        // And the Refounding actually delivers the new root to the lurker.
10695        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
10696        assert_eq!(refounded.root_epoch, Epoch(1));
10697        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
10698        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
10699        let rotations = rekey::collect_rotations(&chunks);
10700        let lurker_x = lurker.public_key().to_bytes();
10701        let delivered = rotations.iter().any(|r| {
10702            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
10703        });
10704        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
10705    }
10706
10707    #[tokio::test]
10708    async fn the_memberlist_pages_past_a_guestbook_flood() {
10709        // The roleless-member half of B1: >500 Guestbook events must not evict an
10710        // honest member's Join from the counted set (an insider can flood throwaway
10711        // Joins to force exactly this). The pager sees them all.
10712        let (_tmp, _guard, _owner) = init_test_db();
10713        let relay = MemoryRelay::new();
10714        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
10715        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
10716
10717        // An honest member's Join (oldest), then 600 throwaway Joins on top.
10718        let honest = Keys::generate();
10719        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
10720        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
10721        relay.publish(&w, &community.relays).await.unwrap();
10722        for i in 0..600u64 {
10723            let throwaway = Keys::generate();
10724            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
10725            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
10726            relay.publish(&w, &community.relays).await.unwrap();
10727        }
10728
10729        let members = memberlist(&relay, &community).await.unwrap();
10730        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
10731    }
10732
10733    #[tokio::test]
10734    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
10735        // An insider floods the next-epoch rekey address (community_root-derived,
10736        // so any member can seal there) with >200 junk 3303s to push the owner's
10737        // genuine rotation out of a single fetch window. The paginated fetch must
10738        // still recover it and adopt.
10739        let (_tmp, _guard, owner) = init_test_db();
10740        let relay = MemoryRelay::new();
10741        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
10742        let new_root = [0xD9; 32];
10743        let new_epoch = Epoch(1);
10744        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10745
10746        // The GENUINE owner rotation lands first (oldest).
10747        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10748
10749        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
10750        // (newer), burying the genuine one past the 200 newest.
10751        let rogue = Keys::generate();
10752        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10753        for i in 0..260u64 {
10754            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
10755            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();
10756            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
10757            relay.publish(&wrap, &community.relays).await.unwrap();
10758        }
10759
10760        let session = SessionGuard::capture();
10761        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
10762        assert_eq!(updated.root_epoch, Epoch(1));
10763        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
10764    }
10765
10766    #[tokio::test]
10767    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
10768        // create_private_channel publishes the key crate, then the channel
10769        // edition, then whole-row-saves. A swap anywhere in that window must
10770        // abort — never mint a channel into the swapped-in account, and never
10771        // leave a half-published key crate adopted locally.
10772        let (bed, owner, _member) = TestBed::new();
10773        bed.swap_to(&owner);
10774        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
10775        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
10776
10777        // The key-crate publish inside create bumps the generation mid-flight.
10778        let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
10779        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
10780        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
10781        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10782        assert_eq!(after.channels.len(), before, "no channel row was written");
10783        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
10784    }
10785
10786    #[tokio::test]
10787    async fn an_uncited_admin_rotation_is_not_adopted() {
10788        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
10789        // authority action, so a just-demoted admin's rotation is never honored by
10790        // a lagging client." An uncited rotation is skipped entirely — neither
10791        // adopted nor allowed to conclude a removal.
10792        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10793        let (_tmp, _guard, _owner) = init_test_db();
10794        let relay = MemoryRelay::new();
10795        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
10796        let priv_id = ChannelId([0x8A; 32]);
10797        let key1 = [0x93; 32];
10798        add_private_channel(&mut community, priv_id, key1, Epoch(1));
10799
10800        let admin = Keys::generate();
10801        let role = Role::admin("cf".repeat(32));
10802        let roster = CommunityRoles {
10803            roles: vec![role.clone()],
10804            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
10805        };
10806        seed_roster_with_heads(&community, &roster, 1_000);
10807
10808        let key2 = [0x94; 32];
10809        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10810        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10811        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10812        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10813        // Authorized admin, correct continuity, my blob present — but NO citation.
10814        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
10815            relay.publish(&e, &community.relays).await.unwrap();
10816        }
10817
10818        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10819        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
10820
10821        // The SAME rotation, cited, is adopted — proving the refusal was the
10822        // citation and not the rank or the continuity.
10823        let cited = my_authority_citation(&community, &admin.public_key());
10824        assert!(cited.is_some(), "the seeded head yields a citation");
10825        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
10826        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() {
10827            relay.publish(&e, &community.relays).await.unwrap();
10828        }
10829        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
10830        assert!(out.updated.is_some(), "the cited rotation IS adopted");
10831    }
10832
10833    #[tokio::test]
10834    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
10835        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
10836        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
10837        // Every follower must converge on the SAME key (the lexicographically
10838        // lowest), so the community never permanently forks. (Retaining the losing
10839        // fork's key for its race-window messages needs a multi-key-per-epoch
10840        // archive — a deferred refinement shared with v1; convergence, the
10841        // security-critical property, is what this pins.)
10842        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
10843        let (_tmp, _guard, _owner) = init_test_db();
10844        let relay = MemoryRelay::new();
10845        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
10846        let priv_id = ChannelId([0xC0; 32]);
10847        let key1 = [0xC1; 32];
10848        add_private_channel(&mut community, priv_id, key1, Epoch(1));
10849
10850        // Two admins (a, b) both hold the Admin role; I hold the channel key.
10851        let (a, b) = (Keys::generate(), Keys::generate());
10852        let role = Role::admin("ce".repeat(32));
10853        let roster = CommunityRoles {
10854            roles: vec![role.clone()],
10855            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
10856        };
10857        seed_roster_with_heads(&community, &roster, 1_000);
10858
10859        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
10860        // same prevcommit — a genuine same-epoch fork.
10861        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
10862        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
10863        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
10864        let key_a = [0x0A; 32];
10865        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
10866        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
10867            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
10868            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() {
10869                relay.publish(&e, &community.relays).await.unwrap();
10870            }
10871        }
10872
10873        let session = SessionGuard::capture();
10874        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
10875        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
10876        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
10877
10878        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
10879        let mut peer = community.clone();
10880        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10881            c.key = Some(key1);
10882            c.epoch = Epoch(1);
10883        }
10884        // Re-run the same fold from the peer's identical starting point → same winner.
10885        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
10886        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
10887    }
10888
10889    #[tokio::test]
10890    async fn create_private_channel_refuses_a_member_without_manage_channels() {
10891        // The local mirror of the reader's gate: an unauthorized member is refused
10892        // BEFORE any publish (no floor pollution, no orphan key crate).
10893        let (bed, owner, member) = TestBed::new();
10894        bed.swap_to(&owner);
10895        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
10896        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10897
10898        bed.swap_to(&member);
10899        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10900        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10901        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
10902        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
10903        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
10904        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
10905    }
10906
10907    // ── Audit regressions ────────────────────────────────────────────────────
10908
10909    #[tokio::test]
10910    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
10911        // The eclipse: community_id commits only to (owner, salt) — both semi-public
10912        // — so a forged invite pairs the REAL triple with an attacker root, and every
10913        // plane derives from it. The join-time owner-genesis check must refuse.
10914        let (bed, owner, member) = TestBed::new();
10915        bed.swap_to(&owner);
10916        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
10917
10918        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
10919        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
10920        forged.community_root = fake.clone();
10921        for ch in &mut forged.channels {
10922            ch.key = fake.clone();
10923        }
10924        let attacker = Keys::generate();
10925        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
10926        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
10927
10928        bed.swap_to(&member);
10929        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10930        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
10931        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
10932        assert!(
10933            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
10934            "a rejected join persists nothing"
10935        );
10936    }
10937
10938    #[tokio::test]
10939    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
10940        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
10941        // so a rotated plane whose metadata an admin last edited carries no
10942        // owner-signed vsk-0. The join anchor there is the community-bound metadata
10943        // head plus any owner-signed edition under the same root.
10944        let (bed, owner, member) = TestBed::new();
10945        bed.swap_to(&owner);
10946        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
10947        let general = community.channels[0].id;
10948
10949        let mut rotated = community.clone();
10950        rotated.community_root = [0x5A; 32];
10951        rotated.root_epoch = Epoch(1);
10952        let admin = Keys::generate();
10953        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
10954        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
10955
10956        bed.swap_to(&member);
10957        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
10958        let session = SessionGuard::capture();
10959        let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10960        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
10961    }
10962
10963    #[tokio::test]
10964    async fn only_an_actual_join_publishes_a_guestbook_join() {
10965        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
10966        // a held community and a cross-device key sync (announce_join=false) must
10967        // both stay silent — each re-publish renders as "<user> has joined" spam.
10968        let (bed, owner, member) = TestBed::new();
10969        bed.swap_to(&owner);
10970        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
10971
10972        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
10973        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
10974            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
10975            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
10976        }
10977        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
10978
10979        bed.swap_to(&member);
10980        let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
10981        let session = SessionGuard::capture();
10982        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10983        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
10984
10985        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
10986        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
10987
10988        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10989        crate::db::community::delete_community(&cid_hex).unwrap();
10990        accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
10991        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
10992    }
10993
10994    #[tokio::test]
10995    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
10996        // The fallback's second half is load-bearing: a community-bound metadata
10997        // head alone is self-signable by anyone who knows the (public) community_id.
10998        let (bed, owner, member) = TestBed::new();
10999        bed.swap_to(&owner);
11000        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
11001
11002        let mut rotated = community.clone();
11003        rotated.community_root = [0x5B; 32];
11004        rotated.root_epoch = Epoch(1);
11005        let attacker = Keys::generate();
11006        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
11007
11008        bed.swap_to(&member);
11009        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11010        let session = SessionGuard::capture();
11011        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11012        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
11013    }
11014
11015    #[tokio::test]
11016    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
11017        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
11018        // an owner-signed genesis, so owner material without it stays insufficient.
11019        let (bed, owner, member) = TestBed::new();
11020        bed.swap_to(&owner);
11021        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
11022        let general = community.channels[0].id;
11023
11024        let mut fake = community.clone();
11025        fake.community_root = [0x5C; 32]; // epoch stays 0
11026        let admin = Keys::generate();
11027        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11028        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11029
11030        bed.swap_to(&member);
11031        let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11032        let session = SessionGuard::capture();
11033        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11034        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11035    }
11036
11037    #[tokio::test]
11038    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11039        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
11040        // joiner addresses it at a plane only the attacker reads. The owner's genuine
11041        // public:false edition must override it on follow.
11042        let (_tmp, _guard, _owner) = init_test_db();
11043        let relay = MemoryRelay::new();
11044        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11045        let general = community.channels[0].id;
11046        let mut poisoned = community.clone();
11047        poisoned.channels[0].private = true;
11048        poisoned.channels[0].key = Some([0x66; 32]);
11049        crate::db::community::save_community_v2(&poisoned).unwrap();
11050
11051        let session = SessionGuard::capture();
11052        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11053        let ch = healed.channel(&general).unwrap();
11054        assert!(!ch.private, "the owner's public declaration overrides the bundle");
11055        assert_eq!(ch.key, None, "a healed public channel derives from the root");
11056    }
11057
11058    #[tokio::test]
11059    async fn a_deleted_channel_does_not_resurrect_on_reload() {
11060        // save_community_v2 must prune orphan channel rows, or a control-follow delete
11061        // reappears (with a stale key) on the next reload.
11062        let (_tmp, _guard, owner) = init_test_db();
11063        let relay = MemoryRelay::new();
11064        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11065        let extra = ChannelId([0x77; 32]);
11066        let session = SessionGuard::capture();
11067        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11068        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11069        assert!(with_extra.channel(&extra).is_some());
11070        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11071        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11072        assert!(after.channel(&extra).is_none());
11073
11074        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11075        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11076        assert_eq!(reloaded.channels.len(), 1);
11077    }
11078
11079    #[tokio::test]
11080    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
11081        // channel_id is the sole DB primary key, so a bundle/replay reusing another
11082        // community's channel_id must NOT overwrite that row. It's skipped (not an
11083        // error — erroring would wedge all of this community's control persistence).
11084        let (_tmp, _guard, _owner) = init_test_db();
11085        let relay = MemoryRelay::new();
11086        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
11087        let a_channel = a.channels[0].id;
11088        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
11089        let b_channel = b.channels[0].id;
11090        // B's set includes a phantom whose id collides with A's channel.
11091        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() });
11092
11093        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
11094        // A's channel row is untouched.
11095        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
11096        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
11097        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
11098        // B keeps its own channel but never acquired a row for the foreign id.
11099        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
11100        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
11101        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
11102    }
11103
11104    /// A single relay that CAPS every query below the page size (modelling a real
11105    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
11106    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
11107    /// truncates the whole set), which is why a MemoryRelay flood test gives false
11108    /// confidence about the production `LiveTransport` behaviour.
11109    struct CappedRelay {
11110        events: Vec<Event>,
11111        cap: usize,
11112    }
11113    #[async_trait::async_trait]
11114    impl Transport for CappedRelay {
11115        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11116        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11117            Ok(())
11118        }
11119        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11120            Ok(())
11121        }
11122        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11123            let mut m: Vec<Event> = self
11124                .events
11125                .iter()
11126                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
11127                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
11128                .cloned()
11129                .collect();
11130            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
11131            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
11132            Ok(m)
11133        }
11134    }
11135
11136    #[tokio::test]
11137    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
11138        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
11139        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
11140        // no cursor steps past — everything older (the genesis editions, a Banlist)
11141        // is unreachable. Compacting THAT view carries only what was read into the
11142        // new epoch, dropping the rest for every member, permanently. Any member can
11143        // build the wall: the plane key comes from the community root they hold.
11144        let (_tmp, _guard, _owner) = init_test_db();
11145        let memory = MemoryRelay::new();
11146        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
11147        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11148
11149        let rogue = Keys::generate();
11150        let mut events: Vec<Event> = Vec::new();
11151        for i in 0..FOLLOW_PAGE {
11152            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
11153            let rumor = control::build_edition_rumor(
11154                rogue.public_key(),
11155                vsk::CHANNEL_METADATA,
11156                &[0xAB; 32],
11157                1,
11158                None,
11159                &content,
11160                9_000,
11161                None,
11162            );
11163            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
11164            events.push(w);
11165        }
11166        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
11167
11168        let err = refound_community(&relay, &community, &[])
11169            .await
11170            .expect_err("a plane that can't be read whole must never be compacted");
11171        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
11172    }
11173
11174    #[tokio::test]
11175    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
11176        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
11177        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
11178        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
11179        // timestamps so `until = now` includes everything and the walk is deterministic.
11180        let (_tmp, _guard, owner) = init_test_db();
11181        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
11182        let g = control::genesis(&owner, meta, 1_000).unwrap();
11183        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
11184
11185        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11186        let rogue = Keys::generate();
11187        let mut events: Vec<Event> = g.wraps.to_vec();
11188        for i in 0..250u64 {
11189            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
11190            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
11191            events.push(wrap);
11192        }
11193        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
11194        let relay = CappedRelay { events, cap: 100 };
11195        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
11196        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
11197    }
11198
11199    #[tokio::test]
11200    async fn accept_parked_invite_joins_from_the_stored_bundle() {
11201        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
11202        // from the stored bundle (re-verifying the owner root over the network).
11203        let (bed, owner, member) = TestBed::new();
11204        bed.swap_to(&owner);
11205        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
11206        let general = community.channels[0].id;
11207        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
11208        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
11209        let bundle_json = serde_json::to_string(&bundle).unwrap();
11210        let inviter_hex = owner.keys.public_key().to_hex();
11211
11212        bed.swap_to(&member);
11213        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
11214        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
11215        assert!(joined.identity.verify());
11216        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
11217        // The join seeded the verified fold as the member's initial floor, so their
11218        // first follow can't roll below the state the join just showed.
11219        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11220        assert!(
11221            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
11222            "the joiner's control floor is seeded from the join-time fold"
11223        );
11224
11225        // The Guestbook memberlist now folds both participants.
11226        bed.swap_to(&owner);
11227        let members = memberlist(&bed.relay, &community).await.unwrap();
11228        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
11229    }
11230
11231    #[tokio::test]
11232    async fn accept_parked_invite_rejects_a_forged_root() {
11233        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
11234        // accept — the shared accept path re-verifies the owner root, so a parked invite
11235        // gets the same eclipse protection as a live one.
11236        let (_tmp, _guard, _owner) = init_test_db();
11237        let relay = MemoryRelay::new();
11238        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
11239        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11240        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11241        forged.community_root = fake.clone();
11242        for ch in &mut forged.channels {
11243            ch.key = fake.clone();
11244        }
11245        let bundle_json = serde_json::to_string(&forged).unwrap();
11246
11247        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
11248        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
11249    }
11250
11251    #[test]
11252    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
11253        // The protocol discriminator the facade list/accept relies on: a v2 bundle
11254        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
11255        // one does not, so a parked invite routes to the right accept path.
11256        let owner = Keys::generate();
11257        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
11258        let hex = crate::simd::hex::bytes_to_hex_32;
11259        let v2 = invite::CommunityInvite {
11260            community_id: hex(&identity.community_id.0),
11261            owner: hex(&identity.owner_xonly),
11262            owner_salt: hex(&identity.owner_salt),
11263            community_root: hex(&[0x11; 32]),
11264            root_epoch: 0,
11265            channels: vec![],
11266            relays: vec!["wss://r".into()],
11267            name: "V2".into(),
11268            icon: None,
11269            expires_at: None,
11270            creator_npub: None,
11271            label: None,
11272            extra: Default::default(),
11273        };
11274        let v2_json = serde_json::to_string(&v2).unwrap();
11275        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
11276        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
11277        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
11278    }
11279
11280    #[tokio::test]
11281    async fn verify_rejects_a_cross_community_owner_edition_replay() {
11282        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
11283        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
11284        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
11285        // a different eid — is not a valid proof of T's root. This is why "any owner
11286        // edition" is unsound and the eid==community_id genesis pin is required.
11287        let (_tmp, _guard, owner) = init_test_db();
11288
11289        // Community X (real), owned by `owner`.
11290        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
11291        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
11292        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
11293
11294        // Forged community T: the real owner triple but an ATTACKER-chosen root.
11295        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
11296        let fake_root = [0xEE; 32];
11297        let t = CommunityV2 {
11298            identity: t_identity,
11299            community_root: fake_root,
11300            root_epoch: Epoch(0),
11301            name: "T".into(),
11302            description: None,
11303            icon: None,
11304            banner: None,
11305            meta_custom: None,
11306            meta_extra: Default::default(),
11307            relays: vec!["wss://r".into()],
11308            channels: vec![],
11309            dissolved: false,
11310            created_at_ms: 0,
11311        };
11312        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
11313        // controls the fake root, so they can derive its control group key).
11314        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
11315        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
11316        let relay = MemoryRelay::new();
11317        relay.publish(&replayed, &t.relays).await.unwrap();
11318
11319        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
11320        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
11321    }
11322
11323    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
11324    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
11325    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
11326    /// it never touches real accounts. Run explicitly:
11327    /// ```sh
11328    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
11329    /// ```
11330    #[tokio::test]
11331    #[ignore = "hits a real relay over the network"]
11332    async fn live_smoke_create_send_fetch_on_a_real_relay() {
11333        use crate::community::transport::LiveTransport;
11334        use nostr_sdk::prelude::ToBech32;
11335
11336        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11337        let relays = vec![relay.clone()];
11338
11339        // Isolated account + data dir (a fresh throwaway key — never a real account).
11340        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11341        crate::db::close_database();
11342        crate::db::clear_id_caches();
11343        let tmp = tempfile::tempdir().unwrap();
11344        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
11345        // into elsewhere; otherwise a fresh throwaway.
11346        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
11347            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
11348            Err(_) => Keys::generate(),
11349        };
11350        let npub = keys.public_key().to_bech32().unwrap();
11351        // Off by default (never leak secrets from a committed test); set
11352        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
11353        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
11354            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
11355        }
11356        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
11357        crate::db::set_app_data_dir(tmp.path().to_path_buf());
11358        crate::db::set_current_account(npub.clone()).unwrap();
11359        crate::db::init_database(&npub).unwrap();
11360        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
11361        crate::state::set_my_public_key(keys.public_key());
11362        println!("[smoke] throwaway identity {npub}");
11363
11364        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
11365        let client = crate::nostr_client_builder().build();
11366        client.add_managed_relay(relay.as_str()).await.ok();
11367        client.connect().await;
11368        crate::state::set_nostr_client(client);
11369        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
11370
11371        // Create → send → fetch-back → verify.
11372        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
11373        let general = community.channels[0].id;
11374        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
11375
11376        let text = "hello from a Vector Concord v2 live smoke test";
11377        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
11378        println!("[smoke] sent message {sent_id}");
11379
11380        // Give the relay a moment to store + be ready to serve it.
11381        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
11382
11383        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
11384        let texts: Vec<String> = page
11385            .iter()
11386            .filter_map(|f| match &f.event {
11387                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
11388                _ => None,
11389            })
11390            .collect();
11391        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
11392        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
11393
11394        // Mint a shareable v2 link (the thing a bot hands out).
11395        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
11396        println!("[smoke] invite link: {}", link.url);
11397        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
11398    }
11399
11400    #[tokio::test]
11401    async fn chat_ops_react_edit_delete_round_trip() {
11402        let (bed, owner, _member) = TestBed::new();
11403        bed.swap_to(&owner);
11404        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
11405        let general = community.channels[0].id;
11406        let me_hex = owner.keys.public_key().to_hex();
11407
11408        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
11409        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
11410            .await
11411            .unwrap();
11412        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
11413        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
11414
11415        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11416        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
11417        let mut saw = (false, false, false);
11418        for f in &page {
11419            match &f.event {
11420                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
11421                    assert_eq!(emoji, ":fire:");
11422                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
11423                    saw.0 = true;
11424                }
11425                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
11426                    assert_eq!(new_content, "edited");
11427                    saw.1 = true;
11428                }
11429                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
11430                _ => {}
11431            }
11432        }
11433        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
11434    }
11435
11436    #[tokio::test]
11437    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
11438        let (bed, owner, _member) = TestBed::new();
11439        bed.swap_to(&owner);
11440        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
11441        let general = community.channels[0].id;
11442        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11443
11444        // A live subscriber sees the 21059 wrap and it opens as Typing…
11445        let mut sub = bed.relay.subscribe(Query {
11446            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
11447            authors: vec![group.pk_hex()],
11448            ..Default::default()
11449        });
11450        send_typing(&bed.relay, &community, &general).await.unwrap();
11451        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
11452        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
11453            Ok(ChatEvent::Typing { opened }) => opened,
11454            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
11455        };
11456
11457        // …while nothing durable is stored (relays never keep the ephemeral tier),
11458        // so channel history stays free of typing noise…
11459        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11460        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
11461
11462        // …and no scrub key is retained (there is no durable wrap to ever delete).
11463        assert!(
11464            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
11465            "ephemeral sends must not retain scrub keys"
11466        );
11467    }
11468
11469    #[tokio::test]
11470    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
11471        let (bed, owner, _member) = TestBed::new();
11472        bed.swap_to(&owner);
11473        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
11474        let general = community.channels[0].id;
11475        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11476
11477        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
11478
11479        // Retained: the row maps the rumor id to the exact published wrap, holds the
11480        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
11481        let (keys, outer_hex, relays) =
11482            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
11483        assert_eq!(relays, community.relays);
11484        let wrap_query = Query {
11485            kinds: vec![stream::KIND_WRAP],
11486            authors: vec![group.pk_hex()],
11487            ..Default::default()
11488        };
11489        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11490        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
11491        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
11492
11493        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
11494        let me_hex = owner.keys.public_key().to_hex();
11495        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
11496            .await
11497            .unwrap();
11498        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
11499
11500        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
11501        // scrubs the wrap off the relay via the retained key, then consumes the row.
11502        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11503        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
11504        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
11505        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
11506    }
11507
11508    #[tokio::test]
11509    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
11510        let (bed, owner, _member) = TestBed::new();
11511        bed.swap_to(&owner);
11512        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
11513        let general = community.channels[0].id;
11514        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
11515
11516        // Simulate a pre-retention / other-device send: our message on the relay,
11517        // but no local mapping row.
11518        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
11519        crate::db::community::delete_message_key(&id).unwrap();
11520        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
11521
11522        // A stranger member's message rides the same channel.
11523        let mkeys = Keys::generate();
11524        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
11525        let foreign_id = rumor.id.unwrap().to_hex();
11526        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
11527        bed.relay.publish(&fw, &community.relays).await.unwrap();
11528
11529        // One history open re-derives the mapping for the OWN message…
11530        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11531        let (keys, _outer, relays) =
11532            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
11533        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
11534        assert_eq!(relays, community.relays);
11535
11536        // …and never manufactures one for a foreign author.
11537        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
11538
11539        // The healed row is a working full delete: the shared path scrubs the wrap.
11540        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
11541        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11542        assert!(
11543            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
11544            "healed message scrubbed from the relay"
11545        );
11546    }
11547
11548    #[tokio::test]
11549    async fn send_chat_message_threads_the_reply_and_extra_tags() {
11550        let (bed, owner, _member) = TestBed::new();
11551        bed.swap_to(&owner);
11552        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
11553        let general = community.channels[0].id;
11554        let me_hex = owner.keys.public_key().to_hex();
11555
11556        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
11557        let imeta = nostr_sdk::prelude::Tag::custom(
11558            "imeta",
11559            ["url https://e/blob".to_string(), "m image/png".to_string()],
11560        );
11561        let child_id = send_chat_message(
11562            &bed.relay, &community, &general, "child",
11563            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
11564        )
11565        .await
11566        .unwrap();
11567
11568        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
11569        let child = page
11570            .iter()
11571            .find_map(|f| match &f.event {
11572                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
11573                _ => None,
11574            })
11575            .expect("the reply message round-trips");
11576        let reply = child.1.as_ref().expect("the reply reference is carried");
11577        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
11578        assert_eq!(reply.author, Some(owner.keys.public_key()));
11579        assert!(
11580            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
11581            "the imeta attachment tag rides the rumor verbatim"
11582        );
11583    }
11584
11585    #[tokio::test]
11586    async fn a_kick_needs_kick_authority_and_removes_the_target() {
11587        let (bed, owner, member) = TestBed::new();
11588        bed.swap_to(&owner);
11589        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
11590
11591        // The target announces a Join (as an accepted invite would).
11592        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11593        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
11594        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
11595        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11596        let before = memberlist(&bed.relay, &community).await.unwrap();
11597        assert!(before.contains(&member.keys.public_key()), "the join lands first");
11598
11599        // An unprivileged member's kick of the owner is refused locally…
11600        bed.swap_to(&member);
11601        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
11602        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
11603
11604        // …and the owner (supreme, no grant needed) kicks the member out.
11605        bed.swap_to(&owner);
11606        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11607        let after = memberlist(&bed.relay, &community).await.unwrap();
11608        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
11609        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
11610    }
11611
11612    #[tokio::test]
11613    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
11614        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
11615        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
11616        // legitimately excludes them for that window. A stale Kick landing there used to
11617        // read as an authorized eviction and the client nuked its own community.
11618        let (bed, owner, member) = TestBed::new();
11619        bed.swap_to(&owner);
11620        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
11621        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11622        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
11623        let join = |at: u64, id: u8| guestbook::GuestbookEvent {
11624            rumor_id: [id; 32],
11625            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
11626        };
11627        let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
11628            rumor_id: [id; 32],
11629            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
11630        };
11631
11632        // An authorized kick after their join stands.
11633        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
11634        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
11635
11636        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
11637        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
11638        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
11639
11640        // The catch-up window itself: nothing folded yet decides nothing.
11641        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
11642        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
11643
11644        // And the memberlist is NOT a substitute: with the store empty it excludes them,
11645        // which is exactly the false positive this verdict replaced.
11646        assert!(
11647            !stored_memberlist(&community).unwrap().contains(&m),
11648            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
11649        );
11650    }
11651
11652    /// Seed a roster the way production does: `follow_control` writes the roster
11653    /// AND the folded edition heads in one pass, so a citation against a grant is
11654    /// resolvable. Seeding the roster alone yields a client that can never satisfy
11655    /// any `vac` — a shape no v2 production path produces.
11656    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
11657        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11658        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
11659        for g in &roster.grants {
11660            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
11661            let eid = super::super::derive::grant_locator(community.id(), &m);
11662            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
11663            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
11664        }
11665    }
11666
11667    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
11668    async fn publish_grant_citing(
11669        relay: &MemoryRelay,
11670        community: &CommunityV2,
11671        signer: &Keys,
11672        member: &PublicKey,
11673        role_ids: Vec<String>,
11674        version: u64,
11675        citation: Option<&crate::community::edition::AuthorityCitation>,
11676    ) {
11677        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
11678        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
11679        let prev = head_hash_on_relay(relay, community, &eid).await;
11680        let grant = MemberGrant { member: member.to_hex(), role_ids };
11681        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
11682        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
11683        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
11684        relay.publish(&wrap, &community.relays).await.unwrap();
11685    }
11686
11687    #[tokio::test]
11688    async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
11689        // CORD-04 §5 on the CONTROL PLANE: "a verifier won't act on the edition
11690        // until it has synced at least that Grant". The citation resolves against
11691        // the heads THIS fold accepted — an external floor would refuse every
11692        // non-owner edition on a bootstrap and the roster could never fold.
11693        let (bed, owner, admin) = TestBed::new();
11694        bed.swap_to(&owner);
11695        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
11696        let admin_pk = admin.keys.public_key();
11697        let rid = "c3".repeat(32);
11698        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
11699        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
11700
11701        // The admin grants a bystander, citing NOTHING.
11702        // A LOWER role (position 5) — an admin at position 1 may grant beneath
11703        // themselves but never at their own rank (equal cannot act on equal).
11704        let low_rid = "c4".repeat(32);
11705        let mut low = admin_role(&low_rid, Permissions::admin().0);
11706        low.position = 5;
11707        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
11708
11709        let bystander = Keys::generate().public_key();
11710        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
11711        let view = fetch_authority(&bed.relay, &community).await;
11712        assert!(
11713            !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11714            "an uncited non-owner edition is not folded"
11715        );
11716        // The owner's own editions still fold — supreme cites nothing.
11717        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
11718
11719        // Same edition, now citing the admin's real grant: honored. (follow_control
11720        // is what PERSISTS the folded heads a citation is built from.)
11721        let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
11722        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
11723        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11724        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11725        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11726        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11727        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
11728
11729        let view = fetch_authority(&bed.relay, &community).await;
11730        assert!(
11731            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
11732            "the same edition WITH its synced citation folds"
11733        );
11734    }
11735
11736    #[tokio::test]
11737    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
11738        // The invite is deliberately ungated, so a fresh Join can arrive seconds
11739        // BEFORE the unban edition. It must reach the store (banned = a fold
11740        // verdict, not a storage verdict) so the unban resurrects the member —
11741        // dropped at ingest, they stayed invisible forever.
11742        let (bed, owner, member) = TestBed::new();
11743        bed.swap_to(&owner);
11744        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
11745        let member_pk = member.keys.public_key();
11746        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11747
11748        // Locally banned (edition folded at t=1000s), with the outliving mark.
11749        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
11750        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
11751
11752        // Their Join lands 60s after the ban mark, while the banlist still says banned.
11753        let join = guestbook::GuestbookEvent {
11754            rumor_id: [9u8; 32],
11755            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
11756        };
11757        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
11758        assert!(
11759            !stored_memberlist(&community).unwrap().contains(&member_pk),
11760            "while banned, the fold keeps them out"
11761        );
11762
11763        // The unban folds: same store, no refetch needed — the Join resurrects them.
11764        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
11765        assert!(
11766            stored_memberlist(&community).unwrap().contains(&member_pk),
11767            "after the unban the raced Join makes them a member again"
11768        );
11769    }
11770
11771    #[tokio::test]
11772    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
11773        // The ban→unban race: a Ban's refound buries the old root over several
11774        // publishes while a concurrently-issued command still holds the
11775        // pre-commit struct. That unban used to land on the buried control
11776        // plane — "succeeding" while no reader would ever fold it — and a
11777        // concurrently-minted invite stranded its joiner on the dead epoch.
11778        let (bed, owner, member) = TestBed::new();
11779        bed.swap_to(&owner);
11780        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
11781        let member_pk = member.keys.public_key();
11782
11783        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
11784        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
11785
11786        // The stale-struct unban is REFUSED (retryable), never misdirected.
11787        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
11788        assert!(err.contains("re-founded"), "unban: {err}");
11789        // A stale invite must not mint dead-epoch key material.
11790        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
11791        assert!(err.contains("re-founded"), "invite: {err}");
11792        // Neither is a kick allowed to ride the buried guestbook.
11793        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
11794        assert!(err.contains("re-founded"), "kick: {err}");
11795
11796        // The retry path: a fresh load lands the unban on the LIVING plane.
11797        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11798        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
11799        let view = fetch_authority(&bed.relay, &fresh).await;
11800        assert!(view.banned.is_empty(), "the retried unban actually unbans");
11801    }
11802
11803    #[tokio::test]
11804    async fn an_uncited_kick_from_an_admin_is_not_honored() {
11805        // CORD-04 §5: a non-owner authority action must name the Grant it acts
11806        // under, and the reader refuses until it holds that Grant. Emitting the
11807        // `vac` without checking it buys nothing — a demoted admin's kick would
11808        // still land on any client that hadn't synced the demotion.
11809        let (bed, owner, member) = TestBed::new();
11810        bed.swap_to(&owner);
11811        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
11812        let admin = Keys::generate();
11813        let member_pk = member.keys.public_key();
11814        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
11815
11816        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11817        let view = fetch_authority(&bed.relay, &community).await;
11818        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
11819
11820        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
11821        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
11822        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
11823        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
11824
11825        let joined = guestbook::GuestbookEvent {
11826            rumor_id: [1u8; 32],
11827            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
11828        };
11829        let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
11830            rumor_id: [id; 32],
11831            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
11832        };
11833        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
11834        let empty_bans = std::collections::BTreeSet::new();
11835        let empty_marks = std::collections::BTreeMap::new();
11836        let fold = |evs: &[guestbook::GuestbookEvent]| {
11837            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
11838        };
11839
11840        assert!(
11841            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
11842            "an uncited kick from an admin is not honored"
11843        );
11844        assert!(
11845            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
11846            "the same kick WITH its synced citation removes them"
11847        );
11848    }
11849
11850    #[tokio::test]
11851    async fn kicking_an_admin_strips_their_roles_first() {
11852        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
11853        // strip leaves the target out of the memberlist but still holding every
11854        // management bit, so every client keeps honoring their control editions.
11855        let (bed, owner, member) = TestBed::new();
11856        bed.swap_to(&owner);
11857        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
11858        let member_pk = member.keys.public_key();
11859        let member_hex = member_pk.to_hex();
11860        let owner_hex = owner.keys.public_key().to_hex();
11861
11862        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11863        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
11864
11865        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
11866
11867        let view = fetch_authority(&bed.relay, &community).await;
11868        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
11869        assert!(
11870            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
11871            "a kicked admin holds no bit"
11872        );
11873        assert!(
11874            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
11875            "and the directive still removed them"
11876        );
11877    }
11878
11879    #[tokio::test]
11880    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
11881        let (bed, owner, member) = TestBed::new();
11882        bed.swap_to(&owner);
11883        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
11884        let member_pk = member.keys.public_key();
11885        let member_hex = member_pk.to_hex();
11886        let owner_hex = owner.keys.public_key().to_hex();
11887
11888        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11889        let view = fetch_authority(&bed.relay, &community).await;
11890        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
11891        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
11892
11893        // A second grant (any device) converges on the SAME role entity — and a
11894        // repeat is a no-op, not a version bump.
11895        let second = Keys::generate().public_key();
11896        grant_admin(&bed.relay, &community, &second).await.unwrap();
11897        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11898        let view = fetch_authority(&bed.relay, &community).await;
11899        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
11900        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
11901        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
11902        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
11903
11904        // Revoke strips ONLY the admin role and de-authorizes.
11905        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
11906        let view = fetch_authority(&bed.relay, &community).await;
11907        assert!(!view.roles.is_admin(&member_hex), "revoked");
11908        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
11909        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
11910    }
11911
11912    #[tokio::test]
11913    async fn follow_control_persists_the_roster_for_sync_local_reads() {
11914        let (bed, owner, member) = TestBed::new();
11915        bed.swap_to(&owner);
11916        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
11917        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11918        let member_hex = member.keys.public_key().to_hex();
11919        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11920
11921        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
11922        let session = crate::state::SessionGuard::capture();
11923        follow_control(&bed.relay, &community, &session).await.unwrap();
11924        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11925        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
11926
11927        // A withholding relay serves nothing — an empty fold raises no gap flag, and
11928        // the stored roster must be RETAINED, never wiped.
11929        let withholding = MemoryRelay::new();
11930        let _ = follow_control(&withholding, &community, &session).await;
11931        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11932        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
11933
11934        // A real revocation (a NEWER grant edition) does replace it.
11935        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11936        follow_control(&bed.relay, &community, &session).await.unwrap();
11937        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11938        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
11939    }
11940
11941    #[tokio::test]
11942    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
11943        let (bed, owner, member) = TestBed::new();
11944        bed.swap_to(&owner);
11945        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
11946
11947        bed.swap_to(&member);
11948        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
11949        assert!(err.contains("owner"), "refused before any publish: {err}");
11950
11951        // The deterministic admin-role entity stays unsquatted — the owner's later
11952        // legitimate mint is version 1 and folds cleanly.
11953        bed.swap_to(&owner);
11954        let view = fetch_authority(&bed.relay, &community).await;
11955        assert!(view.roles.roles.is_empty(), "no role edition landed");
11956        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
11957        let view = fetch_authority(&bed.relay, &community).await;
11958        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
11959    }
11960
11961    #[tokio::test]
11962    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
11963        let (bed, owner, member) = TestBed::new();
11964        bed.swap_to(&owner);
11965        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
11966        let member_pk = member.keys.public_key();
11967
11968        // The member already holds a Mod role, granted through the real send path
11969        // (so this device's floors track both entities).
11970        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
11971        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
11972        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
11973
11974        // A relay that withholds the control plane must refuse the merge — a blind
11975        // push would erase the Mod role at a higher version.
11976        let withholding = MemoryRelay::new();
11977        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
11978        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
11979
11980        // Against the full relay the merge preserves the Mod role.
11981        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
11982        let view = fetch_authority(&bed.relay, &community).await;
11983        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
11984        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
11985        assert!(grant.role_ids.contains(&mod_rid));
11986    }
11987
11988    #[tokio::test]
11989    async fn fetch_authority_reflects_a_granted_admin() {
11990        let (bed, owner, member) = TestBed::new();
11991        bed.swap_to(&owner);
11992        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
11993        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
11994        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11995        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
11996
11997        let view = fetch_authority(&bed.relay, &community).await;
11998        let member_hex = member.keys.public_key().to_hex();
11999        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
12000        assert!(
12001            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
12002            "an ADMIN_ALL grant carries KICK"
12003        );
12004        assert!(view.banned.is_empty());
12005    }
12006}