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        // Founding mask: v1 admin roles predate PIN_MESSAGES, so requiring the
218        // widened ADMIN_ALL would silently demote every migrating v1 admin.
219        if !v1_roles.effective_permissions(&grant.member).contains(Permissions::ADMIN_FOUNDING_MASK) {
220            continue; // not a full admin → plain member on v2 (never escalated)
221        }
222        if banned.contains(&grant.member) {
223            continue; // banned → no authority on v2, don't re-arm a future unban
224        }
225        let Ok(member) = PublicKey::parse(&grant.member) else { continue };
226        if member == owner {
227            continue; // supreme by identity — never needs a grant
228        }
229        grant_admin(transport, twin, &member).await?;
230    }
231    Ok(())
232}
233
234/// The twin's JoinMaterial — the membership subset sealed into the migration `m`.
235pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
236    join_material(twin)
237}
238
239/// Send a text message to a channel. Derives the channel's Chat-Plane group key
240/// (community_root for a Public channel, the channel key for a Private one),
241/// seals it encrypted, and publishes. Returns the message's rumor id (hex).
242pub async fn send_message<T: Transport + ?Sized>(
243    transport: &T,
244    community: &CommunityV2,
245    channel_id: &ChannelId,
246    content: &str,
247) -> Result<String, String> {
248    send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
249}
250
251/// Full chat send: threaded reply (NIP-C7 `q`, the parent's `(rumor_id, author)`
252/// hex pair), NIP-30 custom-emoji pairs, and verbatim extra tags (NIP-92 `imeta`
253/// attachments). Returns the message's rumor id (hex).
254pub async fn send_chat_message<T: Transport + ?Sized>(
255    transport: &T,
256    community: &CommunityV2,
257    channel_id: &ChannelId,
258    content: &str,
259    reply_to: Option<(&str, &str)>,
260    emoji: &[(&str, &str)],
261    extra_tags: Vec<nostr_sdk::prelude::Tag>,
262) -> Result<String, String> {
263    send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
264}
265
266/// [`send_chat_message`] with an explicit event time. The rumor id is a pure
267/// function of its inputs, so a GUI that picks `at_ms` can precompute the id for
268/// its optimistic pending row — the in-process echo and the finalize then key
269/// the SAME id (the exact v1 pending → sent contract).
270#[allow(clippy::too_many_arguments)]
271pub async fn send_chat_message_at<T: Transport + ?Sized>(
272    transport: &T,
273    community: &CommunityV2,
274    channel_id: &ChannelId,
275    content: &str,
276    reply_to: Option<(&str, &str)>,
277    emoji: &[(&str, &str)],
278    extra_tags: Vec<nostr_sdk::prelude::Tag>,
279    at_ms: u64,
280) -> Result<String, String> {
281    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
282    let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
283    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
284}
285
286/// React to a channel message (kind 7, NIP-25 shape). `target_id_hex` /
287/// `target_author_hex` name the reacted-to message; `target_kind` is its rumor
288/// kind (`kind::MESSAGE`, or `kind::COMMENT` for a threaded reply); `emoji`
289/// carries the NIP-30 pair when `emoji_content` is a custom `:shortcode:`.
290#[allow(clippy::too_many_arguments)]
291pub async fn send_reaction<T: Transport + ?Sized>(
292    transport: &T,
293    community: &CommunityV2,
294    channel_id: &ChannelId,
295    target_id_hex: &str,
296    target_author_hex: &str,
297    target_kind: u16,
298    emoji_content: &str,
299    emoji: Option<(&str, &str)>,
300) -> Result<String, String> {
301    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
302    let at_ms = now_ms();
303    let rumor =
304        chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
305    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
306}
307
308/// Edit one of your own messages (kind 3302): peers re-render `target_id_hex`
309/// with the replacement text. Author-enforced on the read side — only the
310/// original author's edit folds.
311pub async fn send_edit<T: Transport + ?Sized>(
312    transport: &T,
313    community: &CommunityV2,
314    channel_id: &ChannelId,
315    target_id_hex: &str,
316    new_content: &str,
317) -> Result<String, String> {
318    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
319    let at_ms = now_ms();
320    let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
321    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
322}
323
324/// Cooperative in-plane delete (kind 5, NIP-09 semantics): peers stop rendering
325/// `target_id_hex`. The wrap ciphertext on relays is scrubbed separately via the
326/// retained per-message stream key (see `publish_chat`).
327pub async fn send_delete<T: Transport + ?Sized>(
328    transport: &T,
329    community: &CommunityV2,
330    channel_id: &ChannelId,
331    target_id_hex: &str,
332    target_kind: u16,
333) -> Result<String, String> {
334    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
335    let at_ms = now_ms();
336    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
337    let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
338    // §7: deleting one's own pinned message obliges the immediate omitting
339    // edition. Hooked HERE and not only at ingest — the local delete drops the
340    // row before the relay echo returns, so the echo can't re-authorize and
341    // the ingest hook never sees an own delete.
342    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
343    spawn_pin_duty(&ch_hex, target_id_hex, None);
344    Ok(id)
345}
346
347/// Moderation-hide: remove SOMEONE ELSE's message under `MANAGE_MESSAGES`
348/// (CORD-04 §3/§5). Same kind-5 the author's own delete uses — CORD defines no
349/// separate hide, the authority is what differs, and every reader re-derives it
350/// from the seal's real npub against the folded Roster.
351///
352/// Gated locally against the same predicate peers enforce, so the button can't
353/// promise what the plane will refuse; a non-owner cites the Grant it acts under.
354/// `target_author` comes from the caller's resident copy — you can only moderate
355/// a message you can see.
356pub async fn moderation_delete<T: Transport + ?Sized>(
357    transport: &T,
358    community: &CommunityV2,
359    channel_id: &ChannelId,
360    target_id_hex: &str,
361    target_kind: u16,
362    target_author: &PublicKey,
363) -> Result<String, String> {
364    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
365    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
366    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
367        return Err("this community is dissolved — it accepts no new moderation actions".to_string());
368    }
369    let owner_hex = community.owner()?.to_hex();
370    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
371    if !crate::community::moderation::can_hide(
372        Some(&owner_hex),
373        &roster,
374        &author_pk.to_hex(),
375        &target_author.to_hex(),
376    ) {
377        return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
378    }
379    let at_ms = now_ms();
380    let citation = required_authority_citation(community, &author_pk)?;
381    let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
382    let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
383    // §7: a moderation-hide of a pinned message obliges the omission too, and
384    // the moderator here provably holds the bit's neighbourhood (MANAGE_MESSAGES
385    // curators usually hold PIN_MESSAGES); the duty itself re-checks.
386    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
387    spawn_pin_duty(&ch_hex, target_id_hex, None);
388    Ok(id)
389}
390
391/// WebXDC realtime peer signal (kind 3310) — the v2 twin of v1's
392/// `publish_webxdc_signal`: the same shared content shape, sealed on the
393/// channel's chat plane, DURABLE (a reopening peer backfills a recent ad).
394/// Signed by the member's real identity — a member can't forge another
395/// player's presence. Failure is non-fatal to callers (the next re-advertise
396/// covers a missed ad).
397pub async fn send_webxdc_signal<T: Transport + ?Sized>(
398    transport: &T,
399    community: &CommunityV2,
400    channel_id: &ChannelId,
401    topic_id: &str,
402    node_addr: Option<&str>,
403) -> Result<(), String> {
404    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
405    let at_ms = now_ms();
406    let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
407    let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
408    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
409}
410
411/// Ephemeral typing indicator (kind 23311 in a 21059 wrap — relays never store it).
412pub async fn send_typing<T: Transport + ?Sized>(
413    transport: &T,
414    community: &CommunityV2,
415    channel_id: &ChannelId,
416) -> Result<(), String> {
417    let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
418    let at_ms = now_ms();
419    let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
420    publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
421}
422
423/// Everything a chat-plane send needs: local keys, the channel's group key +
424/// epoch, and the session snapshot taken BEFORE any await. Refuses a dissolved
425/// community (every honest member sealed it read-only) and a keyless Private
426/// channel — deriving from the root would post to the public plane; its key
427/// arrives over the rekey plane.
428fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
429    let session = SessionGuard::capture();
430    let author_pk = me_pk()?;
431    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
432    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
433        return Err("this community has been dissolved".to_string());
434    }
435    // A self-ban: every honest peer drops our events (CORD-04 §4) and the send
436    // echo would silently no-op, so fail loudly instead of a message that seems
437    // to send but shows up nowhere.
438    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
439        return Err("you are banned from this community".to_string());
440    }
441    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
442    if ch.private && ch.key.is_none() {
443        return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
444    }
445    let (secret, epoch) = community.channel_secret(ch);
446    Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
447}
448
449/// Seal one chat rumor, re-check the session, publish, and echo the send into the
450/// shared store. Returns the rumor id (hex).
451#[allow(clippy::too_many_arguments)]
452async fn publish_chat<T: Transport + ?Sized>(
453    transport: &T,
454    community: &CommunityV2,
455    session: &SessionGuard,
456    group: &GroupKey,
457    author_pk: PublicKey,
458    channel_id: &ChannelId,
459    epoch: Epoch,
460    rumor: nostr_sdk::prelude::UnsignedEvent,
461    at_ms: u64,
462    ephemeral: bool,
463) -> Result<String, String> {
464    let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
465    let signer = crate::signer::active_signer()?;
466    let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
467        .map_err(|e| e.to_string())?;
468    if !session.is_valid() {
469        return Err("account changed before send".to_string());
470    }
471    transport.publish(&wrap, &community.relays).await?;
472    // Retain the wrap's signing key (the group stream key) keyed by rumor id so a
473    // full delete can NIP-09 this exact wrap off relays (same-author rule, honored
474    // everywhere — the discarded p-tag pair only works on recipient-delete relays).
475    // Frozen per-message so later rekeys can't strand it. Session-gated: the publish
476    // straddled network I/O.
477    if !ephemeral {
478        if !session.is_valid() {
479            return Ok(rumor_id);
480        }
481        crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
482    }
483    // Local echo (v1 parity): open our OWN wrap through the exact inbound path so
484    // send-then-read works with no listen loop, and the relay's re-delivery dedups
485    // against this row instead of re-firing callbacks. Best-effort — the publish
486    // already succeeded. Ephemeral kinds (typing) apply to nothing and skip out.
487    if !ephemeral {
488        if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
489            let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
490            let outcome = {
491                let mut st = crate::state::STATE.lock().await;
492                if !session.is_valid() {
493                    return Ok(rumor_id); // swapped on the lock await — never echo into another account.
494                }
495                super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
496            };
497            if let Some(outcome) = outcome {
498                if !session.is_valid() {
499                    return Ok(rumor_id);
500                }
501                super::inbound::persist_chat(&channel_hex, &outcome).await;
502            }
503        }
504    }
505    Ok(rumor_id)
506}
507
508/// A chat event opened from a channel fetch, tagged with the epoch its key
509/// decrypted under.
510pub struct FetchedEvent {
511    pub event: ChatEvent,
512    pub epoch: Epoch,
513}
514
515/// Self-heal scrub-key retention for an OWN rumor seen during a history open:
516/// pre-retention and other-device sends stay fully deletable, because the wrap's
517/// signing key is the derivable group stream key — only this rumor→wrap mapping
518/// was ever missing locally. No-op for foreign authors, kinds the UI can't
519/// delete, and already-retained rows. Best-effort: a store failure never breaks
520/// the fetch.
521fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
522    if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
523        return;
524    }
525    let opened = event.opened();
526    if crate::state::my_public_key() != Some(opened.author) {
527        return;
528    }
529    let rumor_hex = opened.rumor_id.to_hex();
530    // Only fill a confirmed gap — never clobber a send-time row, never write
531    // when the store can't be read.
532    if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
533        return;
534    }
535    if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
536        // The UI caches full-vs-limited delete verdicts per message; tell it this
537        // one just flipped so it re-resolves without an app restart.
538        crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
539    }
540}
541
542/// Fetch a channel's newest messages — one page of [`fetch_channel_history`].
543/// `limit` is one relay-side bound across the whole epoch-author OR-set, not
544/// per epoch; deeper history pages backwards via the walk.
545pub async fn fetch_channel<T: Transport + ?Sized>(
546    transport: &T,
547    community: &CommunityV2,
548    channel_id: &ChannelId,
549    limit: usize,
550) -> Result<Vec<FetchedEvent>, String> {
551    fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
552}
553
554/// Walk a channel's history newest-first (CORD-03 §3 "clients load a Channel
555/// newest-first and paginate backwards"), querying every held epoch's Chat-Plane
556/// address one `page`-sized query at a time until `max_pages`, a drained relay,
557/// or `keep_paging` returns false for a page (the caller's "I already hold
558/// these" early stop — consulted only on pages that opened something, so junk
559/// at the address can't fake exhaustion). Pages step by INCLUSIVE `until` with
560/// wrap-id dedup, so a page boundary landing mid-second can't skip siblings; a
561/// full page of only-already-seen wraps is a same-second WALL (relay filters
562/// are second-granular) and steps past it accepting that unseen same-second
563/// siblings beyond the relay cap are unreachable — logged, and a protocol-level
564/// limitation (the `ms` tag can't be filtered server-side).
565///
566/// Returns everything opened, deduped by rumor id, oldest→newest.
567pub async fn fetch_channel_history<T: Transport + ?Sized>(
568    transport: &T,
569    community: &CommunityV2,
570    channel_id: &ChannelId,
571    page: usize,
572    max_pages: usize,
573    since: Option<u64>,
574    // Unix-seconds upper bound for the FIRST page (inclusive) — the back-paging
575    // cursor. `None` starts at the newest.
576    start_until: Option<u64>,
577    evidence: crate::community::transport::Evidence,
578    mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
579) -> Result<Vec<FetchedEvent>, String> {
580    // Guards the opportunistic scrub-key heals below — the fetch loop straddles
581    // network I/O, and an account swap must not write into the new account's DB.
582    let session = SessionGuard::capture();
583    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
584    // A Public channel reads across EVERY held base-root epoch, and a Private one
585    // across its OWN held epochs (CORD-03 §3), so history spanning a rotation stays
586    // continuous either way. A keyless Private channel is unreadable — never derived
587    // from the root (that would address the public plane).
588    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
589    let coords: Vec<([u8; 32], Epoch)> = if ch.private {
590        let Some(current) = ch.key else {
591            return Ok(Vec::new());
592        };
593        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
594        let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
595        if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
596            held.push((ch.epoch, current));
597        }
598        // Only real grants are archived, but keep the invariant local: a private
599        // plane is never read with the root value.
600        held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
601    } else {
602        let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
603        if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
604            roots.push((community.root_epoch, community.community_root));
605        }
606        roots.into_iter().map(|(ep, root)| (root, ep)).collect()
607    };
608    if coords.is_empty() {
609        return Ok(Vec::new());
610    }
611
612    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
613    let mut seen_rumors = std::collections::HashSet::new();
614    let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
615    let mut until: Option<u64> = start_until;
616    let mut oldest: Option<u64> = None;
617    for _ in 0..max_pages {
618        // Fetch each held epoch's Chat-Plane AUTHED AS that plane key. AUTH-gating
619        // relays (Ditto) require the connection authed as the author queried and
620        // reject a multi-author REQ ("all authors must be authenticated"), so a
621        // single merged fetch returns nothing there — the latest messages under a
622        // freshly-adopted epoch never load. Per-plane authed fetches + union.
623        let mut wraps: Vec<Event> = Vec::new();
624        let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
625        for (secret, epoch) in &coords {
626            let plane = channel_group_key(secret, channel_id, *epoch);
627            let q = Query {
628                kinds: vec![stream::KIND_WRAP],
629                authors: vec![plane.pk_hex()],
630                since,
631                until,
632                limit: Some(page),
633                evidence,
634                ..Default::default()
635            };
636            if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
637                for e in evs {
638                    if wrap_ids.insert(e.id) {
639                        wraps.push(e);
640                    }
641                }
642            }
643        }
644        if wraps.is_empty() {
645            break;
646        }
647        let mut fresh = 0usize;
648        let mut page_events: Vec<FetchedEvent> = Vec::new();
649        for wrap in &wraps {
650            if !seen_wraps.insert(wrap.id) {
651                continue;
652            }
653            fresh += 1;
654            let at = wrap.created_at.as_secs();
655            if oldest.is_none_or(|o| at < o) {
656                oldest = Some(at);
657            }
658            // Select the epoch whose group key authored this wrap (no trial decrypt).
659            for (secret, epoch) in &coords {
660                let group = channel_group_key(secret, channel_id, *epoch);
661                if wrap.pubkey != group.pk() {
662                    continue;
663                }
664                if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
665                    let id = event.opened().rumor_id;
666                    if seen_rumors.insert(id) {
667                        if session.is_valid() {
668                            heal_own_wrap_key(&event, &group, &community.relays);
669                        }
670                        page_events.push(FetchedEvent { event, epoch: *epoch });
671                    }
672                }
673                break;
674            }
675        }
676        if fresh == 0 {
677            if wraps.len() < page {
678                break; // drained — the relay has nothing older.
679            }
680            // A full page of already-seen wraps: a same-second WALL. Step past it;
681            // same-second siblings beyond the relay's cap are unreachable by a
682            // second-granular filter.
683            let Some(o) = oldest else { break };
684            if o == 0 {
685                break;
686            }
687            crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
688            until = Some(o - 1);
689            continue;
690        }
691        let stop = !page_events.is_empty() && !keep_paging(&page_events);
692        out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
693        if stop {
694            break; // the caller holds everything from here back.
695        }
696        until = oldest; // inclusive — wrap-id dedup absorbs the boundary overlap.
697    }
698    out.sort_by_key(|(ms, _)| *ms);
699    Ok(out.into_iter().map(|(_, e)| e).collect())
700}
701
702// ── Invites (CORD-05) ────────────────────────────────────────────────────────
703
704/// Who an invite bundle is FOR — which decides the Private-Channel keys it may
705/// carry (CORD-05 §1 vs §2).
706///
707/// A **Link** has no recipient: "anyone the link reaches can join", so its
708/// audience holds no Role by construction and is entitled to no Private Channel
709/// at all. A **Member** is a specific npub whose entitlement is computable.
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
711pub enum BundleAudience {
712    /// A public link (33301 bundle event): public channels only.
713    Link,
714    /// A direct invite (3313) to this npub: may carry Private-Channel keys.
715    Member(PublicKey),
716}
717
718/// Build the §1 invite bundle for this community, scoped to `audience`. A
719/// Public channel carries the `community_root` as its "key" (the joiner derives
720/// the real secret from the root); a Private one its own key — and only for a
721/// Member the folded roster shows entitled. The bundle self-certifies the owner,
722/// so the inviter's identity is irrelevant to trust.
723pub fn bundle_of(
724    community: &CommunityV2,
725    audience: BundleAudience,
726    creator: Option<PublicKey>,
727    expires_at_ms: Option<u64>,
728    label: Option<String>,
729) -> CommunityInvite {
730    bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
731}
732
733/// [`bundle_of`] settling entitlement against a Grant this client JUST published
734/// (`with`/`without` role ids), since the fold lags its own publish. This is the
735/// grant-vend path (CORD-03 "delivered on grant").
736pub fn bundle_of_with_overlay(
737    community: &CommunityV2,
738    audience: BundleAudience,
739    creator: Option<PublicKey>,
740    expires_at_ms: Option<u64>,
741    label: Option<String>,
742    with: &[String],
743    without: &[String],
744) -> CommunityInvite {
745    let hex = crate::simd::hex::bytes_to_hex_32;
746    let cid_hex = hex(&community.identity.community_id.0);
747    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
748    let owner_hex = community.owner().ok().map(|o| o.to_hex());
749    let recipient_hex = match audience {
750        BundleAudience::Link => None,
751        BundleAudience::Member(pk) => Some(pk.to_hex()),
752    };
753    let channels = community
754        .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
755        .into_iter()
756        .map(|c| invite::ChannelGrant {
757            id: hex(&c.id.0),
758            key: hex(&c.key.unwrap_or(community.community_root)),
759            epoch: c.epoch.0,
760            name: c.name.clone(),
761        })
762        .collect();
763    CommunityInvite {
764        community_id: hex(&community.identity.community_id.0),
765        owner: hex(&community.identity.owner_xonly),
766        owner_salt: hex(&community.identity.owner_salt),
767        community_root: hex(&community.community_root),
768        root_epoch: community.root_epoch.0,
769        channels,
770        relays: community.relays.clone(),
771        name: community.name.clone(),
772        // Mint-time snapshot so a parked invite renders the real logo before any
773        // fold; the Control Plane stays the authority after joining.
774        icon: community.icon.clone(),
775        expires_at: expires_at_ms,
776        creator_npub: creator.map(|p| p.to_hex()),
777        label,
778        extra: Default::default(),
779    }
780}
781
782/// Gift-wrap a Direct Invite (kind 3313) of this community straight to `recipient`
783/// and publish it to the community relays. `expires_at_ms` (unix ms) optionally
784/// bounds its shelf life; `label` is echoed in the joiner's Guestbook Join. The
785/// bundle hands over the keys; the recipient consents by accepting (nothing joins
786/// on receipt). Returns the wrap.
787pub async fn send_direct_invite<T: Transport + ?Sized>(
788    transport: &T,
789    community: &CommunityV2,
790    recipient: &PublicKey,
791    expires_at_ms: Option<u64>,
792    label: Option<String>,
793) -> Result<Event, String> {
794    let session = SessionGuard::capture();
795    // A stale bundle is worse than a stale edit: it hands the joiner keys to a
796    // buried epoch, and their client later self-evicts on the rekey exclusion.
797    assert_current_root(community)?;
798    let signer = crate::signer::active_signer()?;
799    let inviter_pk = me_pk()?;
800    let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
801    let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
802    if !session.is_valid() {
803        return Err("account changed before sending invite".to_string());
804    }
805    transport.publish(&wrap, &community.relays).await?;
806    Ok(wrap)
807}
808
809/// A minted public link: the shareable URL plus the addressable bundle event to
810/// publish and the link keypair to retain (in the Invite List) for later refresh
811/// or revocation.
812pub struct MintedLink {
813    pub url: String,
814    pub bundle_event: Event,
815    pub link_signer: Keys,
816    pub token: [u8; super::derive::TOKEN_LEN],
817    /// Unix ms, mirrored from the bundle. The Invite List is the creator's only
818    /// record of it, and the Registry prunes on it — the coordinate a member
819    /// folds carries no expiry, so a lapsed link the creator never pruned reads
820    /// as a live door forever (CORD-05 §4/§5).
821    pub expires_at_ms: Option<u64>,
822    pub label: Option<String>,
823}
824
825/// Mint a public invite link for this community: a fresh token + link keypair, the
826/// bundle encrypted under the token key and published at `(33301, link_signer,
827/// "")`, and the `base/invite/<naddr>#<fragment>` URL. `base` is the deep-link
828/// domain (e.g. `https://vectorapp.io`); the fragment carries the token + bootstrap
829/// relays and never reaches a server.
830pub async fn mint_public_link<T: Transport + ?Sized>(
831    transport: &T,
832    community: &CommunityV2,
833    base: &str,
834    expires_at_ms: Option<u64>,
835    label: Option<String>,
836) -> Result<MintedLink, String> {
837    let session = SessionGuard::capture();
838    // Readers ignore a registry whose author lacks CREATE_INVITE (it can't even
839    // flip the community Public) — refuse before minting a link nobody honors.
840    ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
841    let mut token = [0u8; super::derive::TOKEN_LEN];
842    token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
843    let link_signer = Keys::generate();
844    let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
845    let bundle_key = super::derive::invite_bundle_key(&token);
846    let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
847    let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
848
849    if !session.is_valid() {
850        return Err("account changed before minting link".to_string());
851    }
852    transport.publish_durable(&bundle_event, &community.relays).await?;
853    let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
854    // Sync the link across the creator's devices (13303) + publish the Registry
855    // (vsk-8) so members see the community is Public. Best-effort — the link works
856    // without the sync.
857    let _ = record_minted_link(transport, community, &minted).await;
858    // Local mirror so `list_public_invites` stays a sync local read (v1 parity);
859    // the 13303 list remains the cross-device record. Re-check the session: the
860    // publishes above straddled awaits, and this write must not land account A's
861    // link (secret token included) in a swapped-in account's DB.
862    if session.is_valid() {
863        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
864        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
865        let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
866    }
867    Ok(minted)
868}
869
870// ── The Invite Registry (vsk 8) + Invite List (13303), CORD-05 §4/§5 ──────────
871
872/// Fetch the creator's own 13303 Invite List from `relays` (newest wins; a
873/// decrypt/parse failure is "no news", never a clobber of the local mirror).
874/// Transport failure is Err, NOT None: the 13303 is REPLACEABLE, so a caller
875/// that mistakes "couldn't reach the relays" for "no list yet" and publishes a
876/// fresh one wipes every link minted on other devices. Full evidence for the
877/// same reason — this read feeds replaceable-event writes.
878async fn fetch_invite_list<T: Transport + ?Sized>(
879    transport: &T,
880    relays: &[String],
881) -> Result<Option<invite::InviteList>, String> {
882    let signer = crate::signer::active_signer()?;
883    let my_pk = me_pk()?;
884    let query = Query {
885        kinds: vec![super::kind::INVITE_LIST],
886        authors: vec![my_pk.to_hex()],
887        limit: Some(4),
888        evidence: crate::community::transport::Evidence::Full,
889        ..Default::default()
890    };
891    let events = transport.fetch(&query, relays).await?;
892    let mut best: Option<(u64, invite::InviteList)> = None;
893    for e in events {
894        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
895            let at = e.created_at.as_secs();
896            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
897                best = Some((at, l));
898            }
899        }
900    }
901    Ok(best.map(|(_, l)| l))
902}
903
904/// The creator's LIVE link-signer pubkeys for one community — the Registry's
905/// content (CORD-05 §5), derived from the stored link secrets.
906///
907/// Live means neither tombstoned nor EXPIRED. An expired link cannot be joined
908/// (`InviteBundle::expired`, CORD-05 §1), so leaving it in the Registry states
909/// a door that isn't there: the aggregate never empties, the community reads
910/// Public forever, and every gate hanging off that reading silently inverts.
911fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
912    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
913    list.entries
914        .iter()
915        .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
916        .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
917        .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
918        .collect()
919}
920
921/// Publish the creator's Registry (vsk-8) edition — their live link signers for this
922/// community — so members fold it into the Public/Private source of truth (a
923/// non-empty aggregate = Public).
924async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
925    let my_pk = me_pk()?;
926    let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
927    let content = invite::build_registry_content(live_signers);
928    publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
929    // Refresh the cache from the PLANE, not from `live_signers`: the column aggregates
930    // every creator, so writing only mine would clobber theirs, and a union could never
931    // shrink — retiring the last link would leave the community reading Public forever.
932    refresh_invite_registry_cache(transport, community, session).await;
933    Ok(())
934}
935
936/// Re-fold the whole invite Registry and cache it, so Public/Private stays a sync
937/// LOCAL read. Silent no-op when the plane can't be read whole — a partial fold
938/// would under-state Public, leaving a live link open behind a ban.
939async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
940    let Ok(owner) = community.owner() else { return };
941    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
942    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
943    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
944        .unwrap_or_default()
945        .into_iter()
946        .filter(|(_, f)| f.0 == community.root_epoch.0)
947        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
948        .collect();
949    let authority = fold_authority(community, &editions, &floors);
950    let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
951    if session.is_valid() {
952        let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
953        let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
954    }
955}
956
957/// Record a freshly-minted public link across the creator's devices: append it to the
958/// 13303 Invite List and refresh the Registry (CORD-05 §4/§5).
959async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
960    let session = SessionGuard::capture();
961    let signer = crate::signer::active_signer()?;
962    let my_pk = me_pk()?;
963    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
964    let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
965    // Err aborts the sync half (the link's bundle already published durably;
966    // a retry re-records it) — an unreachable relay set must never be mistaken
967    // for "no list yet" and clobber the replaceable 13303. Ok(None) IS a fresh
968    // creator's honest first list.
969    let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
970    if !list.entries.iter().any(|e| e.token == token_hex) {
971        list.entries.push(invite::InviteEntry {
972            token: token_hex,
973            signer_sk: minted.link_signer.secret_key().to_secret_hex(),
974            community_id: cid_hex.clone(),
975            url: minted.url.clone(),
976            label: minted.label.clone(),
977            created_at: now_ms() / 1000,
978            expires_at: minted.expires_at_ms,
979            extra: Default::default(),
980        });
981    }
982    if !session.is_valid() {
983        return Err("account changed during link record".to_string());
984    }
985    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
986    transport.publish(&event, &community.relays).await?;
987    let signers = live_signers_for(&list, &cid_hex, now_ms());
988    publish_invite_registry(transport, community, &session, &signers).await
989}
990
991/// Revoke a public link by its token hex (CORD-05 §2/§5): re-post its coordinate as a
992/// revocation tombstone (retiring the bundle behind the URL, so a fetcher finds the
993/// grave), tombstone the Invite List entry, and refresh the Registry. Retiring the
994/// LAST live link empties the Registry → the community reads Private (a Refounding is
995/// the owner's separate read-cut).
996pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
997    let session = SessionGuard::capture();
998    let signer = crate::signer::active_signer()?;
999    let my_pk = me_pk()?;
1000    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1001    let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1002    let entry = list
1003        .entries
1004        .iter()
1005        .find(|e| e.token == token_hex && e.community_id == cid_hex)
1006        .cloned()
1007        .ok_or("no such link in the invite list")?;
1008    // Re-post the bundle coordinate as a revocation tombstone (creator-signed).
1009    let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1010    let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1011    if !session.is_valid() {
1012        return Err("account changed during revoke".to_string());
1013    }
1014    transport.publish_durable(&revocation, &community.relays).await?;
1015    // Tombstone the Invite List entry (permanent — a stale device can't resurrect it).
1016    list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1017    list.entries.retain(|e| e.token != token_hex);
1018    let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1019    transport.publish(&event, &community.relays).await?;
1020    let signers = live_signers_for(&list, &cid_hex, now_ms());
1021    publish_invite_registry(transport, community, &session, &signers).await?;
1022    // Drop the local mirror row (sibling of the mint-time save) — only if still our session.
1023    if session.is_valid() {
1024        let _ = crate::db::community::delete_public_invite(token_hex);
1025    }
1026    Ok(())
1027}
1028
1029/// Refresh every live public link's bundle behind its stable URL (CORD-05 §2) — e.g.
1030/// after a Rekey/Refounding rolled the keys — by re-posting the bundle at the same
1031/// coordinate with the CURRENT community state, so a link shared once keeps working
1032/// across rotations. Best-effort.
1033pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1034    let session = SessionGuard::capture();
1035    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1036    // Fetch inline (not via fetch_invite_list) so a TRANSPORT FAILURE propagates as
1037    // Err — the caller (a post-refounding refresh) must be able to retry, or live
1038    // links keep serving the PRE-refound root and new joiners land on the dead
1039    // epoch. A genuinely-empty list is Ok (nothing to refresh).
1040    let signer = crate::signer::active_signer()?;
1041    let my_pk = me_pk()?;
1042    let query = Query {
1043        kinds: vec![super::kind::INVITE_LIST],
1044        authors: vec![my_pk.to_hex()],
1045        limit: Some(4),
1046        ..Default::default()
1047    };
1048    let events = transport.fetch(&query, &community.relays).await?;
1049    let mut best: Option<(u64, invite::InviteList)> = None;
1050    for e in events {
1051        if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1052            let at = e.created_at.as_secs();
1053            if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1054                best = Some((at, l));
1055            }
1056        }
1057    }
1058    let Some((_, list)) = best else {
1059        return Ok(());
1060    };
1061    let creator = my_pk;
1062    let now = now_ms();
1063    let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1064    for entry in &list.entries {
1065        if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1066            continue;
1067        }
1068        // An expired link can't be joined, so refreshing it just re-states a
1069        // door that isn't there (CORD-05 §1/§5).
1070        if entry.expires_at.is_some_and(|exp| now > exp) {
1071            continue;
1072        }
1073        let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1074        let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1075        let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1076        let bundle_key = super::derive::invite_bundle_key(&token);
1077        if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1078            if !session.is_valid() {
1079                return Err("account changed during link refresh".to_string());
1080            }
1081            let _ = transport.publish_durable(&event, &community.relays).await;
1082        }
1083    }
1084    // Republish the Registry from the same pruned view. Expiry is the one way a
1085    // link dies with no user action, so without a heal point here the aggregate
1086    // never empties and the community reads Public long after its last door
1087    // shut (CORD-05 §5). Idempotent when nothing lapsed.
1088    //
1089    // Only for a creator who actually minted here: one Invite List spans every
1090    // community, so a member holding links ELSEWHERE would otherwise publish an
1091    // empty Registry edition into this one on every rotation they adopt — a
1092    // control-plane write, and a version bump, for a coordinate they never owned.
1093    let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1094    if !mine_here {
1095        return Ok(());
1096    }
1097    let signers = live_signers_for(&list, &cid_hex, now);
1098    if !session.is_valid() {
1099        return Err("account changed during link refresh".to_string());
1100    }
1101    let _ = publish_invite_registry(transport, community, &session, &signers).await;
1102    Ok(())
1103}
1104
1105/// Whether this community is PUBLIC (CORD-05 §5): fold every creator's Registry
1106/// (vsk-8) that its author is authorized for (`CREATE_INVITE`, bound to their
1107/// coordinate) into an aggregate live-link set — non-empty ⇒ a live link exists ⇒
1108/// Public; empty ⇒ Private. Retiring the last link is what flips it back.
1109pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1110    let Ok(owner) = community.owner() else { return false };
1111    // Truncation fails toward Public: over-stating it only makes a caller take the
1112    // stronger remedy (privatise + re-found + reissue), while under-stating it
1113    // leaves a live link open behind a ban.
1114    let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1115    let cid = community.id();
1116    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1117    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1118        .unwrap_or_default()
1119        .into_iter()
1120        .filter(|(_, f)| f.0 == community.root_epoch.0)
1121        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1122        .collect();
1123    let authority = fold_authority(community, &editions, &floors);
1124    !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1125}
1126
1127/// Page the WHOLE control plane, not the newest window: a registry pushed out of a
1128/// single page reads as retired, and any member can push it out since the plane key
1129/// comes from the community root they hold. `None` = it could NOT be read whole
1130/// (transport failure, same-second wall, pager depth), so a caller must not mistake
1131/// an empty fold for absence.
1132async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1133    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1134    let mut editions: Vec<ParsedEdition> = Vec::new();
1135    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1136    let mut oldest: Option<u64> = None;
1137    let mut until: Option<u64> = None;
1138    for page in 0..COMPACT_MAX_PAGES {
1139        // Quorum, DECLARED (the until→Full transport floor is gone): these
1140        // control reads tolerate a partial union — their fold semantics are
1141        // fail-safe on gaps (seeded banlists, withheld roster cache).
1142        let query = Query {
1143            kinds: vec![stream::KIND_WRAP],
1144            authors: vec![control.pk_hex()],
1145            until,
1146            limit: Some(FOLLOW_PAGE),
1147            evidence: crate::community::transport::Evidence::Quorum,
1148            ..Default::default()
1149        };
1150        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1151        let mut fresh = 0usize;
1152        for w in &wraps {
1153            if !seen_wraps.insert(w.id) {
1154                continue;
1155            }
1156            fresh += 1;
1157            let at = w.created_at.as_secs();
1158            if oldest.is_none_or(|o| at < o) {
1159                oldest = Some(at);
1160            }
1161            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1162                editions.push(ed);
1163            }
1164        }
1165        if fresh == 0 {
1166            if wraps.len() >= FOLLOW_PAGE {
1167                return None; // same-second wall: the plane can't be read whole
1168            }
1169            return Some(editions);
1170        }
1171        until = oldest;
1172        if page + 1 == COMPACT_MAX_PAGES {
1173            return None;
1174        }
1175    }
1176    Some(editions)
1177}
1178
1179/// The live link coordinates PER AUTHORISED CREATOR across every Registry (vsk-8);
1180/// non-empty ⇒ the Community is Public, and the per-creator split is what drives
1181/// "X has N active invite links". Pure over an already-fetched edition set so the
1182/// on-demand probe and the control follow fold it identically.
1183fn live_invite_link_sets(
1184    cid: &crate::community::CommunityId,
1185    owner_hex: &str,
1186    editions: &[ParsedEdition],
1187    authority: &AuthoritySet,
1188    floors: &Floors,
1189) -> Vec<crate::db::community::InviteLinkSetRow> {
1190    use crate::community::roles::Permissions;
1191    use std::collections::BTreeMap;
1192    let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1193    for e in editions {
1194        if e.vsk == vsk::INVITE_LINKS {
1195            by_eid.entry(e.entity_id).or_default().push(e);
1196        }
1197    }
1198    let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1199    for (eid, group) in &by_eid {
1200        // Authority BEFORE the fold, matching `apply_control_fold`. `fold_head`
1201        // picks an equal-version winner author-blind (lowest inner id, which an
1202        // author can grind), so folding first would let any member occupy the head
1203        // slot and have the whole registry dropped by the check below — silently
1204        // retiring a live invite link, i.e. flipping the community to Private.
1205        let authed: Vec<&ParsedEdition> = group
1206            .iter()
1207            .copied()
1208            .filter(|p| {
1209                let author = p.author.to_hex();
1210                // The creator must hold CREATE_INVITE, not be banned, AND own this coordinate.
1211                !authority.banned.contains(&author)
1212                    && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1213                    && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1214            })
1215            .collect();
1216        if authed.is_empty() {
1217            continue;
1218        }
1219        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1220        let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1221        if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1222            if signers.is_empty() {
1223                continue; // a creator who retired every link is absent, not a zero row
1224            }
1225            sets.push(crate::db::community::InviteLinkSetRow {
1226                creator_hex: authed[hi].author.to_hex(),
1227                locators: signers.iter().map(|p| p.to_hex()).collect(),
1228            });
1229        }
1230    }
1231    sets
1232}
1233
1234/// Flatten per-creator sets into the aggregate the `invite_registry` column holds.
1235fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1236    let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1237    flat.sort();
1238    flat.dedup();
1239    flat
1240}
1241
1242/// Accept an already-unwrapped bundle: verify the owner commitment AND that the
1243/// delivered community_root is genuinely the owner's, persist the community, and
1244/// announce a Guestbook Join (with invite attribution). Shared tail of both accept
1245/// paths. Takes the caller's `SessionGuard` (captured BEFORE any network fetch the
1246/// caller did) so the `is_valid()` gate straddles that I/O.
1247async fn accept_bundle<T: Transport + ?Sized>(
1248    transport: &T,
1249    session: &SessionGuard,
1250    bundle: &CommunityInvite,
1251    invited_by: Option<PublicKey>,
1252    announce_join: bool,
1253) -> Result<CommunityV2, String> {
1254    let signer = crate::signer::active_signer()?;
1255    let my_pk = me_pk()?;
1256    let at_ms = now_ms();
1257    // Expiry gate: a past invite still previews but must not join (CORD-05 §1).
1258    if bundle.expired(at_ms) {
1259        return Err("this invite has expired".to_string());
1260    }
1261    // `from_bundle` re-validates bounds + the owner commitment fail-closed.
1262    let community = CommunityV2::from_bundle(bundle, at_ms)?;
1263    // Captured before the save below: a re-accept of a held community must not
1264    // re-announce a membership this account already declared.
1265    let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1266
1267    // Authenticate the delivered community_root before trusting it. The owner
1268    // commitment proves WHO the owner is, but community_root (and channel keys) are
1269    // NOT in that commitment, so a forged invite can pair a real (id, owner, salt)
1270    // with an attacker-chosen root and silently partition the joiner onto planes
1271    // only the attacker controls. Requiring the owner's genesis to open under the
1272    // delivered root closes that eclipse; also reconciles channel classification.
1273    // A preview verified the SAME (id, root) moments ago → reuse its fold instead
1274    // of re-walking the plane (the bundle re-fetch above kept the revocation gate).
1275    let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1276        v.session.is_valid()
1277            && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1278            && v.community_id == community.id().0
1279            && v.community_root == community.community_root
1280    });
1281    let (community, join_heads, join_banlist) = match handoff {
1282        Some(v) => {
1283            let mut c = v.folded;
1284            // The preview holds no acquisition time — stamp the JOIN's.
1285            c.created_at_ms = at_ms;
1286            (c, v.heads, v.banned)
1287        }
1288        None => verify_owner_root_and_reconcile(transport, community).await?,
1289    };
1290
1291    // A dissolved community is a grave (CORD-02 §9): refuse to join it.
1292    if is_dissolved(transport, &community).await {
1293        return Err("this community has been dissolved".to_string());
1294    }
1295
1296    // Join-time ban gate (CORD-04 §4, Armada parity): an honest client refuses to join a
1297    // community whose authorized banlist names it — before the Guestbook Join publishes
1298    // and before any local write. Every door funnels through here (direct invite, parked,
1299    // public link, migration), so none of them needs its own exclusion.
1300    if join_banlist.contains(&my_pk.to_hex()) {
1301        return Err("you are banned from this community".to_string());
1302    }
1303
1304    // The account must not have swapped since the guard was captured (which was
1305    // before any fetch the caller / the verify above performed) — else we'd write
1306    // A's join into B.
1307    if !session.is_valid() {
1308        return Err("account changed during join".to_string());
1309    }
1310    // Seed the verified heads as the initial refuse-downgrade floor BEFORE the
1311    // community row lands (floors-then-state, so a mid-seed error can't leave saved
1312    // state outrunning its floor); the first post-join follow then can't persist a
1313    // state below what this join already showed.
1314    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1315    for h in &join_heads {
1316        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)?;
1317    }
1318    crate::db::community::save_community_v2(&community)?;
1319    // Archive the joined root at its epoch, so this member reads Public-channel
1320    // history from their join epoch onward across later Refoundings (CORD-03 §3).
1321    let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1322    // Same for each granted Private-channel key: the archive is what lets its
1323    // history stay readable after the channel rotates away from this key.
1324    for ch in &community.channels {
1325        if let (true, Some(key)) = (ch.private, ch.key) {
1326            let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1327        }
1328    }
1329
1330    // Announce our Guestbook Join, echoing the invite attribution when present.
1331    // Only an ACTUAL join speaks: a re-accept of a held community, or a
1332    // cross-device key sync (announce_join=false), is not a membership event —
1333    // the account's original Join already stands in the guestbook, and every
1334    // re-publish renders as "<user> has joined" spam for the whole community.
1335    if announce_join && !already_held {
1336        let attribution = invited_by
1337            .map(|p| p.to_hex())
1338            .or_else(|| bundle.creator_npub.clone())
1339            .zip(Some(bundle.label.clone().unwrap_or_default()));
1340        let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1341        let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1342        let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1343        if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1344            let _ = transport.publish(&join_wrap, &community.relays).await;
1345        }
1346    }
1347
1348    // Record the membership across devices (CORD-02 §8). The inline attempt covers the
1349    // happy path; anything else hands off to the durable retry, because an unrecorded
1350    // join is what strands a community behind a stale tombstone.
1351    match republish_community_list(transport, Some(community.id())).await {
1352        Ok(true) => {}
1353        Ok(false) => republish_community_list_durable(Some(*community.id())),
1354        Err(e) => {
1355            crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1356            republish_community_list_durable(Some(*community.id()));
1357        }
1358    }
1359    Ok(community)
1360}
1361
1362/// Prove the delivered `community_root` is genuinely the owner's, and reconcile
1363/// channel classification from the owner's editions. `community_id` commits only
1364/// to `(owner_xonly, owner_salt)` — both semi-public (they ride every bundle and
1365/// every synced Community List) — so a forged invite can present a real community's
1366/// id/owner/salt with an attacker-chosen root; every plane then derives from that
1367/// root, silently eclipsing the joiner onto attacker-controlled addresses while the
1368/// owner commitment still "verifies". The defense: the owner's genesis metadata
1369/// edition (vsk-0, `eid == community_id`) only opens under the AUTHENTIC root — an
1370/// attacker can't forge the owner's seal — so its presence on the control plane
1371/// derived from the delivered root proves that root. On a ROTATED plane (epoch > 0)
1372/// the compaction may have carried an admin-signed metadata head instead (CORD-06
1373/// re-wraps heads with their original signatures), so the anchor there is the
1374/// community-bound metadata head plus any owner-signed edition under the same root.
1375/// Fail-closed: no anchor (forged invite, or relays unreachable) → refuse to join.
1376/// On success, folds the owner's authoritative editions to heal a bundle that
1377/// misclassified a channel.
1378async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1379    transport: &T,
1380    community: CommunityV2,
1381) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1382    let owner = community.owner()?;
1383    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1384    let control_pk = control.pk_hex();
1385
1386    // AUTH-gating relays (ditto-relay's default gates kind-1059) serve a plane's
1387    // wraps ONLY to a connection authenticated AS the stream key — Concord's
1388    // group-addressed wraps aren't p-tagged to the joiner, so the login alone can't
1389    // satisfy the gate and the control plane reads back empty. Register this
1390    // community's stream keys + start the challenge responder so the fetch below
1391    // (whose REQ triggers the relay's AUTH challenge) reads the plane after auth.
1392    super::streamauth::prime(&community);
1393
1394    // Authenticity = the owner's GENESIS metadata edition (vsk-0, `eid ==
1395    // community_id`) at the root-derived control plane. The genesis eid pins it to
1396    // THIS community, and it lives ONLY under the real root — so a forged root can't
1397    // produce one: an edition's seal carries no community binding, but another
1398    // community's genesis has a different eid, and this community's own genesis is
1399    // unreadable without its real root (which the forger lacks). ("Any owner edition"
1400    // is NOT sound: an owner sig from any co-owned community, rewrapped onto the fake
1401    // plane, would pass — reopening the eclipse.) The residual — a T-member replaying
1402    // T's genesis onto a fake root to MITM another T-joiner — is closed only by
1403    // binding the root into community_id (protocol, deferred).
1404    //
1405    // Seed `until` with a FAR-FUTURE constant (NOT now-based), and request
1406    // Evidence::Full EXPLICITLY below: this walk draws an ABSENCE verdict (no
1407    // owner-signed genesis ⇒ reject), which trusts only the completest union —
1408    // an open partial window misses a genesis on a lagging relay (routine over
1409    // Tor). A constant beyond any real created_at clips NOTHING — so neither
1410    // a clock-skewed future-dated genesis nor a >1h-slow-clock joiner is excluded (a
1411    // now-based bound could clip either). Break on an EMPTY page (a short page is a
1412    // relay cap). A forged root walks to exhaustion and rejects; a flood/deep plane
1413    // that buries the genesis past the walk is the deferred protocol residual.
1414    const PAGE: usize = 500;
1415    const MAX_PAGES: usize = 4;
1416    const FAR_FUTURE_SECS: u64 = 4_102_444_800; // ~year 2100 — above any real edition, safe as a relay `until`.
1417    let mut editions: Vec<ParsedEdition> = Vec::new();
1418    let mut all_editions: Vec<ParsedEdition> = Vec::new();
1419    let mut found_genesis = false;
1420    // Rotated planes (CORD-06): compaction re-wraps each entity's CURRENT head with
1421    // its ORIGINAL signature, so if an admin last edited the metadata the plane holds
1422    // no owner-signed vsk-0 at all — the strict genesis anchor is unsatisfiable there.
1423    // Fallback pair for epoch > 0: the community-bound metadata head (any signer) PLUS
1424    // at least one owner-signed edition opened under this root. A non-member forger
1425    // can produce neither; the sibling-community rewrap residual this reopens is the
1426    // same class the spec defers to root-in-id binding.
1427    let mut compacted_metadata = false;
1428    crate::log_debug!(
1429        "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1430        &control_pk[..12], community.root_epoch, community.relays
1431    );
1432    let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1433        found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1434    };
1435    for attempt in 0..2 {
1436        editions.clear();
1437        all_editions.clear();
1438        compacted_metadata = false;
1439        let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1440        let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1441        for page_no in 0..MAX_PAGES {
1442            let query = Query {
1443                kinds: vec![stream::KIND_WRAP],
1444                authors: vec![control_pk.clone()],
1445                until,
1446                limit: Some(PAGE),
1447                evidence: crate::community::transport::Evidence::Full,
1448                ..Default::default()
1449            };
1450            let wraps = transport.fetch(&query, &community.relays).await?;
1451            crate::log_trace!(
1452                "[JoinVerify] attempt {} page {}: fetched {} wraps",
1453                attempt, page_no, wraps.len()
1454            );
1455            // INCLUSIVE `until` + wrap-id dedup: a `-1` step can skip same-second
1456            // siblings at a page boundary (and the genesis with them); re-served
1457            // boundary events are free, and no-new-events means exhausted.
1458            let mut oldest = u64::MAX;
1459            let mut fresh = 0usize;
1460            for w in &wraps {
1461                if !seen_wraps.insert(w.id) {
1462                    continue;
1463                }
1464                fresh += 1;
1465                oldest = oldest.min(w.created_at.as_secs());
1466                if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1467                    crate::log_trace!(
1468                        "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1469                        ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1470                        ed.author == owner, w.created_at.as_secs()
1471                    );
1472                    if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1473                        if ed.author == owner {
1474                            found_genesis = true;
1475                        } else {
1476                            compacted_metadata = true;
1477                        }
1478                    }
1479                    if ed.author == owner {
1480                        editions.push(ed.clone());
1481                    }
1482                    // Any-author set for the join-time authority fold below: the banlist head
1483                    // may be admin-signed, and its authority chains to the owner regardless.
1484                    all_editions.push(ed);
1485                }
1486            }
1487            crate::log_debug!(
1488                "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1489                attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1490            );
1491            if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1492                break; // authenticated, or the relay is exhausted.
1493            }
1494            until = Some(oldest);
1495        }
1496        if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1497            break;
1498        }
1499        if attempt == 0 {
1500            // AUTH-gating relays: the first walk's REQ triggers the NIP-42 challenge,
1501            // but nostr-sdk's own retry re-auths as the USER key — which doesn't
1502            // satisfy a stream-authors gate — and can land before the responder's
1503            // stream-key auth settles, reading the plane back EMPTY. Replay the
1504            // remembered challenges for every registered stream key, then walk once
1505            // more on the settled connection.
1506            if let Some(client) = crate::state::nostr_client() {
1507                super::streamauth::prime_auth(&client, &community.relays).await;
1508            }
1509        }
1510    }
1511    if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1512        return Err(
1513            "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"
1514                .to_string(),
1515        );
1516    }
1517    // Join-time reconcile: the joiner holds no floors yet (empty map → bootstrap per
1518    // entity). The heads this fold verified are returned for the caller to SEED as
1519    // the initial floor once the community row is saved — without that, the first
1520    // post-join follow would bootstrap floor-less and could persist a state BELOW
1521    // what this join already verified and showed.
1522    // Join-time reconcile folds only the owner's editions (genesis-authenticated
1523    // above), and the owner is supreme — so owner-only authority suffices. The full
1524    // roster (admins) folds on the first post-join follow_control.
1525    let empty_floors = Floors::new();
1526    let authority = AuthoritySet::owner_only();
1527    let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1528    // Join-time banlist: fold authority over the ANY-author edition set (roles/grants
1529    // chain to the genesis-verified owner; the banlist head is honored only if its signer
1530    // held BAN). Returned so the accept path can refuse a banned self BEFORE it publishes
1531    // a Guestbook Join — the gate every join door shares (Armada parity, CORD-04 §4).
1532    let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1533    Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1534}
1535
1536/// Accept a Direct Invite: unwrap the 3313 giftwrap (Schnorr-verifying the seal),
1537/// then run the shared accept path. The recipient's consent IS this call. No
1538/// network await precedes the accept, so the guard captured here suffices.
1539pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1540    let session = SessionGuard::capture();
1541    let signer = crate::signer::active_signer()?;
1542    let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1543    accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1544}
1545
1546/// Accept a PARKED Direct Invite from its stored bundle JSON (the wrap was already
1547/// unwrapped + owner-verified at park time). Re-parses through the same fail-closed
1548/// bundle validation, then runs the shared accept path (which re-verifies the owner
1549/// root over the network). `inviter_hex` is the parked seal signer, for Guestbook
1550/// Join attribution.
1551pub async fn accept_parked_invite<T: Transport + ?Sized>(
1552    transport: &T,
1553    bundle_json: &str,
1554    inviter_hex: Option<&str>,
1555) -> Result<CommunityV2, String> {
1556    let session = SessionGuard::capture();
1557    let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1558    let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1559    accept_bundle(transport, &session, &bundle, invited_by, true).await
1560}
1561
1562/// Accept v2 JoinMaterial recovered from a v1→v2 migration dissolution payload (`m`). The
1563/// material IS a bundle's membership subset — rebuild the invite and run the SHARED accept
1564/// path, which re-verifies the owner root over the network and enforces the join-time ban
1565/// gate (a banned-never-cut v1 member who can open `m` is refused here, fail-closed). No
1566/// giftwrap to unwrap: the dissolution already authenticated the owner via its signature.
1567pub async fn accept_migration_material<T: Transport + ?Sized>(
1568    transport: &T,
1569    jm: &super::list::JoinMaterial,
1570) -> Result<CommunityV2, String> {
1571    let session = SessionGuard::capture();
1572    let bundle = material_to_invite(jm);
1573    accept_bundle(transport, &session, &bundle, None, true).await
1574}
1575
1576/// Fetch + decrypt the newest Live bundle at a public link's coordinate
1577/// (`(33301, link_signer, "")`). **Revocation is authoritative-if-present**: if
1578/// ANY signer-valid tombstone is among the fetched events, refuse — never trust
1579/// fetch ordering (a cross-relay union has no global newest-first sort, so a
1580/// stale Live could otherwise win a partial-propagation race). Otherwise pick
1581/// the newest valid Live by `created_at`. Read-only.
1582pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1583    let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1584    // NO `#d` filter, even though the coordinate's `d` is empty (CORD-05 §2). Relays disagree on
1585    // indexing an empty tag value: some answer the REQ and then never EOSE, so the fetch burns its
1586    // whole union grace on every invite. The per-link signer pins the coordinate on its own (it
1587    // signs nothing else), and `parse_bundle_event` re-checks the empty `d` locally.
1588    let query = Query {
1589        kinds: vec![super::kind::INVITE_BUNDLE],
1590        authors: vec![parsed.link_signer.to_hex()],
1591        ..Default::default()
1592    };
1593    let relays = if parsed.bootstrap_relays.is_empty() {
1594        invite::stock_relays()
1595    } else {
1596        parsed.bootstrap_relays.clone()
1597    };
1598    // One bounded retry: a join fired while the pool is still warming (bootstrap
1599    // relays mid-handshake, routine during boot contention) reads back a transport
1600    // error, not an absent bundle. The pool add already happened on the first try,
1601    // so wait for a socket rather than guessing with a fixed sleep.
1602    let events = match transport.fetch(&query, &relays).await {
1603        Ok(evs) => evs,
1604        Err(_) => {
1605            wait_for_bootstrap_relay(&relays).await;
1606            transport.fetch(&query, &relays).await?
1607        }
1608    };
1609    let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1610
1611    // Scan EVERY event: a tombstone beats a Live unconditionally (order-independent).
1612    let mut newest_live: Option<(u64, CommunityInvite)> = None;
1613    for event in &events {
1614        match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1615            Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1616            Ok(invite::BundleState::Live(bundle)) => {
1617                let at = event.created_at.as_secs();
1618                if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1619                    newest_live = Some((at, *bundle));
1620                }
1621            }
1622            Err(_) => {} // a foreign/garbage event at the coordinate — ignore.
1623        }
1624    }
1625    newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1626}
1627
1628/// Wait — bounded — for ANY of the targets to report Connected before a retry:
1629/// the fetch's own warm path bounds its connect wait tighter than a cold TLS
1630/// handshake takes under boot contention.
1631async fn wait_for_bootstrap_relay(relays: &[String]) {
1632    let Some(client) = crate::state::nostr_client() else { return };
1633    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1634    loop {
1635        for url in relays {
1636            if let Ok(Some(relay)) = client.relay(url).await {
1637                if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1638                    return;
1639                }
1640            }
1641        }
1642        if tokio::time::Instant::now() >= deadline {
1643            return;
1644        }
1645        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1646    }
1647}
1648
1649/// The most recent owner-root verification a PREVIEW completed, handed to a join
1650/// so accepting seconds later doesn't re-walk the control plane. Single-slot,
1651/// short-lived, session-guarded, and keyed on `(community_id, community_root)` —
1652/// a different delivered root never matches. The join's own bundle re-fetch is
1653/// untouched, so the revocation gate always runs live.
1654struct VerifiedPreview {
1655    session: SessionGuard,
1656    at: std::time::Instant,
1657    community_id: [u8; 32],
1658    community_root: [u8; 32],
1659    folded: CommunityV2,
1660    heads: Vec<FoldedHead>,
1661    /// The join-time authorized banlist from the SAME verified walk — carried so the
1662    /// handoff path keeps the ban gate (a preview-then-join must not skip it).
1663    banned: std::collections::BTreeSet<String>,
1664}
1665static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1666const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1667
1668/// Read-only rich preview of a public link: the decrypted bundle plus the LATEST
1669/// display metadata folded live from the Control Plane (a v2 bundle deliberately
1670/// carries no icon — the fold is the authority). Owner-root verification rides
1671/// the fold, so a forged-root link can't render a convincing preview; on a
1672/// fold/transport failure the bundle snapshot is the fallback. Nothing persists
1673/// — the caller hasn't joined.
1674pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1675    let bundle = fetch_public_bundle(transport, url).await?;
1676    preview_bundle(transport, &bundle).await
1677}
1678
1679/// The fold half of [`preview_public_link`], over an already-fetched bundle. Split out so a caller
1680/// that only needs the community's IDENTITY can read it off the bundle (it is self-certifying) and
1681/// skip the Control-Plane walk entirely — the walk is the join gate, and `accept_public_link` runs
1682/// it again regardless.
1683pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1684    let community = CommunityV2::from_bundle(bundle, 0)?;
1685    match verify_owner_root_and_reconcile(transport, community.clone()).await {
1686        Ok((folded, heads, banned)) => {
1687            *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1688                session: SessionGuard::capture(),
1689                at: std::time::Instant::now(),
1690                community_id: folded.id().0,
1691                community_root: folded.community_root,
1692                folded: folded.clone(),
1693                heads,
1694                banned,
1695            });
1696            Ok(folded)
1697        }
1698        Err(_) => Ok(community),
1699    }
1700}
1701
1702/// Accept a public invite link: fetch its bundle (revocation-aware) and join.
1703pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1704    // Capture BEFORE the network fetch so the join's is_valid() gate straddles it.
1705    let session = SessionGuard::capture();
1706    let bundle = fetch_public_bundle(transport, url).await?;
1707    if !session.is_valid() {
1708        return Err("account changed during join".to_string());
1709    }
1710    accept_bundle(transport, &session, &bundle, None, true).await
1711}
1712
1713/// Leave a community: publish a Guestbook Leave and tear down the local hold.
1714pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1715    let session = SessionGuard::capture();
1716    let signer = crate::signer::active_signer()?;
1717    let my_pk = me_pk()?;
1718    let at_ms = now_ms();
1719    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1720    let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1721    if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1722        let _ = transport.publish(&wrap, &community.relays).await;
1723    }
1724    if !session.is_valid() {
1725        return Err("account changed during leave".to_string());
1726    }
1727    // Tombstone the membership across devices (CORD-02 §8) BEFORE the local delete,
1728    // to the leaving community's own relays (it's about to be gone locally) —
1729    // best-effort.
1730    let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1731    // The tombstone publish straddled an await — never delete from a swapped-in DB.
1732    if !session.is_valid() {
1733        return Err("account changed during leave".to_string());
1734    }
1735    crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1736    Ok(())
1737}
1738
1739/// Cooperative Kick (CORD-04 §6, Guestbook plane): name the target; every reader
1740/// honors it iff the signer holds KICK and strictly outranks them (the coalesce's
1741/// `can_kick`), so publishing without authority is inert. A kicked member may
1742/// rejoin with a fresh invite — cryptographic severance is the ban/refound path.
1743pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1744    let session = SessionGuard::capture();
1745    assert_current_root(community)?;
1746    let signer = crate::signer::active_signer()?;
1747    let my_pk = me_pk()?;
1748    // Fast local pre-check; readers re-verify independently.
1749    let authority = fetch_authority(transport, community).await;
1750    let owner_hex = community.owner()?.to_hex();
1751    if !authority.roles.can_act_on_member(
1752        &my_pk.to_hex(),
1753        Some(&owner_hex),
1754        &target.to_hex(),
1755        crate::community::roles::Permissions::KICK,
1756    ) {
1757        return Err("not authorized to kick this member".to_string());
1758    }
1759    // CORD-04 §6 composition: a Kick is Role Removal THEN the directive — strip
1760    // first, so the target's rank is gone before the departure lands. Without it a
1761    // kicked admin leaves the memberlist still holding every management bit, and
1762    // every client keeps honoring their control editions.
1763    //
1764    // SKIPPED (not refused) when the strip isn't ours to make: a revoke needs
1765    // MANAGE_ROLES + strict outrank, and a KICK-only moderator still kicks — the
1766    // target just keeps their rank until an authorized strip lands. Each layer
1767    // validates on its own rule, so a missing one is a weaker removal, never a
1768    // broken one. A strip we DO attempt and lose is a hard error: proceeding would
1769    // publish a directive we know leaves rank behind.
1770    let target_hex = target.to_hex();
1771    let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1772    let may_strip = authority.roles.can_act_on_member(
1773        &my_pk.to_hex(),
1774        Some(&owner_hex),
1775        &target_hex,
1776        crate::community::roles::Permissions::MANAGE_ROLES,
1777    );
1778    if holds_roles && may_strip {
1779        grant_roles(transport, community, target, Vec::new())
1780            .await
1781            .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1782        if !session.is_valid() {
1783            return Err("account changed during kick".to_string());
1784        }
1785    }
1786    let at_ms = now_ms();
1787    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1788    // A Kick is an authority action, so it cites its Grant like any other
1789    // (CORD-02 §5 / CORD-04 §5).
1790    let citation = required_authority_citation(community, &my_pk)?;
1791    let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1792    let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1793        .map_err(|e| e.to_string())?;
1794    if !session.is_valid() {
1795        return Err("account changed before send".to_string());
1796    }
1797    transport.publish(&wrap, &community.relays).await?;
1798    Ok(())
1799}
1800
1801/// A community's folded, delegation-authorized authority — the on-demand read
1802/// view (a paged control-plane fetch + fold, nothing persisted). `roles` is the
1803/// owner-seeded authorized roster (shared algebra with v1); `banned` the
1804/// enforced banlist. `floored`/`head_entities` let a writer detect a WITHHELD
1805/// entity (floored locally but no head folded) before replacing it blind.
1806pub struct AuthorityView {
1807    pub roles: crate::community::roles::CommunityRoles,
1808    pub banned: std::collections::BTreeSet<String>,
1809    /// Any authority entity's fold hit a floor gap (withheld / evicted link).
1810    pub gapped: bool,
1811    /// Entity hexes holding a persisted floor at this epoch (all vsk kinds).
1812    pub floored: std::collections::BTreeSet<String>,
1813    /// Authority entities (role/grant/banlist) that folded a head this fetch.
1814    pub head_entities: std::collections::BTreeSet<String>,
1815    /// Ban history (npub hex → secs), outliving the ban so an un-ban raises no phantom.
1816    pub banned_at: std::collections::BTreeMap<String, u64>,
1817}
1818
1819/// Fetch + fold the community's current authority (CORD-04), paging older like
1820/// `follow_control` while the fold is gapped so a busy control plane can't push
1821/// the roster off the newest window. A fetch failure degrades fail-safe:
1822/// owner-only authority plus the PERSISTED banlist — nobody gains standing from
1823/// an outage, and a ban never lifts on withheld data.
1824pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1825    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1826    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1827        .unwrap_or_default()
1828        .into_iter()
1829        .filter(|(_, f)| f.0 == community.root_epoch.0)
1830        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1831        .collect();
1832    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1833
1834    let mut editions: Vec<ParsedEdition> = Vec::new();
1835    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1836    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1837    let mut oldest: Option<u64> = None;
1838    let mut until: Option<u64> = None;
1839    // Seed from an EMPTY fold, not owner_only(): a fold over zero editions yields
1840    // owner-only roles AND retains the PERSISTED banlist. So a first-page transport
1841    // error returns the stored bans (fail-safe), never an empty banlist that would
1842    // silently un-ban on withheld data.
1843    let mut a = fold_authority(community, &[], &floors);
1844    for _ in 0..FOLLOW_MAX_PAGES {
1845        // Quorum, DECLARED (the until→Full transport floor is gone): these
1846        // control reads tolerate a partial union — their fold semantics are
1847        // fail-safe on gaps (seeded banlists, withheld roster cache).
1848        let query = Query {
1849            kinds: vec![stream::KIND_WRAP],
1850            authors: vec![control.pk_hex()],
1851            until,
1852            limit: Some(FOLLOW_PAGE),
1853            evidence: crate::community::transport::Evidence::Quorum,
1854            ..Default::default()
1855        };
1856        let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1857        let mut fresh = 0usize;
1858        for w in &wraps {
1859            if !seen_wraps.insert(w.id) {
1860                continue;
1861            }
1862            fresh += 1;
1863            let at = w.created_at.as_secs();
1864            if oldest.is_none_or(|o| at < o) {
1865                oldest = Some(at);
1866            }
1867            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1868                if seen.insert(ed.inner_id) {
1869                    editions.push(ed);
1870                }
1871            }
1872        }
1873        a = fold_authority(community, &editions, &floors);
1874        if !a.gapped || fresh == 0 {
1875            break;
1876        }
1877        until = oldest;
1878    }
1879    AuthorityView {
1880        roles: a.roles,
1881        banned: a.banned,
1882        gapped: a.gapped,
1883        floored: floors.keys().cloned().collect(),
1884        head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1885        banned_at: a.banned_at,
1886    }
1887}
1888
1889/// Page the Guestbook plane newest-to-oldest, stopping once a page's oldest wrap
1890/// falls below `since_secs` (everything older is already held) or the plane is
1891/// exhausted. Returns the parsed events at/after the window plus the newest wrap
1892/// time seen (the caller's next cursor; `since_secs` when nothing newer arrived).
1893///
1894/// PAGE bound rationale: a single 500-window silently drops a member whose Join
1895/// aged out (organic growth, or an insider flooding throwaway Joins), and
1896/// `refound_community` consumes the fold as its rekey recipient set — a dropped
1897/// member is SEVERED. Beyond this depth a community needs sharding (documented);
1898/// the granted-member union in [`fold_members`] is the consensus-complete
1899/// backstop regardless of Guestbook depth.
1900async fn fetch_guestbook_events<T: Transport + ?Sized>(
1901    transport: &T,
1902    community: &CommunityV2,
1903    since_secs: u64,
1904) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1905    let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1906    const GB_PAGE: usize = 500;
1907    const GB_MAX_PAGES: usize = 12;
1908    let mut events = Vec::new();
1909    let mut newest: u64 = since_secs;
1910    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1911    let mut until: Option<u64> = None;
1912    let mut oldest: Option<u64> = None;
1913    for _ in 0..GB_MAX_PAGES {
1914        // Full: this set becomes the refound's recipient list — a member's
1915        // Join visible only on a minority relay must not be severed.
1916        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() };
1917        let wraps = transport.fetch(&query, &community.relays).await?;
1918        let mut fresh = 0usize;
1919        for wrap in &wraps {
1920            if !seen.insert(wrap.id) {
1921                continue;
1922            }
1923            fresh += 1;
1924            let at = wrap.created_at.as_secs();
1925            if oldest.is_none_or(|o| at < o) {
1926                oldest = Some(at);
1927            }
1928            if at > newest {
1929                newest = at;
1930            }
1931            // Older than the cursor window — already held; skip the decrypt.
1932            if at < since_secs {
1933                continue;
1934            }
1935            if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1936                if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1937                    events.push(ev);
1938                }
1939            }
1940        }
1941        if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1942            break;
1943        }
1944        match oldest {
1945            Some(o) if o > 0 => until = Some(o),
1946            _ => break,
1947        }
1948    }
1949    Ok((events, newest))
1950}
1951
1952/// The shared membership fold: coalesce Guestbook events under the community's
1953/// authority (owner-supreme kicks, refounder snapshots), union observed authors
1954/// plus every roster grantee, subtract the banlist, and pin the proven owner.
1955/// One implementation, so the live and stored reads can't drift.
1956fn fold_members(
1957    community: &CommunityV2,
1958    events: &[guestbook::GuestbookEvent],
1959    mut observed: std::collections::BTreeMap<PublicKey, u64>,
1960    roles: &crate::community::roles::CommunityRoles,
1961    banlist: &std::collections::BTreeSet<PublicKey>,
1962    banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1963) -> Result<Vec<PublicKey>, String> {
1964    let owner = community.owner()?;
1965    let owner_hex = owner.to_hex();
1966    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1967
1968    // CONSENSUS-COMPLETE backstop: every member the folded roster GRANTS a role to
1969    // is provably a member (a Grant binds member_xonly, CORD-02 A.6) — count them
1970    // even if their Join aged out of the Guestbook entirely and they never posted.
1971    // This is what keeps a Refounding from severing a lurking admin. `observed`
1972    // carries them at ts 0 (presence, not recency); the banlist subtraction below
1973    // still removes a banned grantee whose grant wasn't yet stripped.
1974    for g in &roles.grants {
1975        if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1976            observed.entry(pk).or_insert(0);
1977        }
1978    }
1979
1980    // Snapshot authority (CORD-02 §5): a refounding rolls `root_epoch` and re-seeds the
1981    // new epoch's Guestbook with a 3312 snapshot of the survivors. Only the OWNER's snapshot is
1982    // honored here, so a silent survivor stays in the memberlist across an owner refound
1983    // without re-posting. A genesis community (root_epoch 0) has no refounder, hence no
1984    // snapshot power. KNOWN GAP (do not "fix" unilaterally — CORD-04/06 + Armada): the refound
1985    // send/receive gates authorize any BAN-holder to refound, but their snapshot is NOT honored
1986    // here, so a non-owner admin's refound drops silent survivors (incl. migration roster seeds)
1987    // until they re-post. Binding the minting rotator into snapshot authority is a spec change.
1988    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1989    // Kick authority (CORD-04 §5/§6): the signer must cite a Grant we've synced AND
1990    // hold KICK AND strictly outrank the target (the owner is supreme; equal cannot
1991    // kick equal).
1992    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1993        let actor_hex = actor.to_hex();
1994        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1995            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1996    };
1997    let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1998    let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1999    // The owner is a member by definition, independent of any fetched Join.
2000    if !banlist.contains(&owner) {
2001        members.insert(owner);
2002    }
2003    Ok(members.into_iter().collect())
2004}
2005
2006/// Did the AUTHORIZED Guestbook coalesce rule `member` KICKED, per the stored plane?
2007///
2008/// This is the only sound basis for acting on a kick against ourselves. The
2009/// memberlist is the wrong question: it also folds the banlist, the ban marks and
2010/// observed authors, so a member whose Guestbook hasn't caught up yet — a REJOIN,
2011/// where the store starts empty while the control fold has already re-derived their
2012/// old ban mark — is absent from it while being perfectly joined. Coalescing asks
2013/// only "what is the latest authorized entry for this npub", so a fresh Join
2014/// supersedes an old Kick and an empty store yields no verdict at all.
2015pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2016    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2017    let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2018        return false;
2019    };
2020    let Ok(owner) = community.owner() else { return false };
2021    let owner_hex = owner.to_hex();
2022    let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2023    let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2024    let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2025        let actor_hex = actor.to_hex();
2026        citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2027            && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2028    };
2029    matches!(
2030        guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2031        Some(st) if st.verdict == guestbook::Verdict::Kicked
2032    )
2033}
2034
2035/// Catch the persisted Guestbook up from its stored cursor (a fresh hold seeds
2036/// from zero). The fetch straddles the network, so the session re-checks before
2037/// the store writes. Returns the events that were NEW to the store — the caller
2038/// surfaces them (presence lines) and refreshes on non-empty.
2039pub async fn sync_guestbook<T: Transport + ?Sized>(
2040    transport: &T,
2041    community: &CommunityV2,
2042    session: &SessionGuard,
2043) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2044    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2045    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2046    // Overlap one second so a same-second boundary event can't slip the cursor;
2047    // the rumor-id merge below dedups the re-fetched edge.
2048    let since = cursor.saturating_sub(1);
2049    let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2050    if !session.is_valid() {
2051        return Err("account changed during guestbook sync".to_string());
2052    }
2053    let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2054    let mut added = Vec::new();
2055    for ev in fresh {
2056        if !known.contains(&ev.rumor_id) {
2057            events.push(ev.clone());
2058            added.push(ev);
2059        }
2060    }
2061    if !added.is_empty() || newest > cursor {
2062        crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2063    }
2064    Ok(added)
2065}
2066
2067/// Fold ONE live guestbook event into the store (the realtime path — no fetch).
2068/// Returns whether it was new.
2069pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2070    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2071    let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2072    if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2073        return Ok(false);
2074    }
2075    events.push(ev);
2076    crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2077    Ok(true)
2078}
2079
2080/// The memberlist from LOCAL state only: the persisted Guestbook, plus locally
2081/// observed authors (the synced events DB), plus roster grantees, minus the
2082/// banlist. Instant and offline-correct; [`sync_guestbook`] (post-join, boot,
2083/// reconnect, live ingest) keeps the store current. The live [`memberlist`]
2084/// remains the authoritative walk — a refounding's rekey recipient set must
2085/// never trust a possibly-stale store.
2086pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2087    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2088    let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2089    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2090    for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2091        if let Ok(pk) = PublicKey::parse(&npub) {
2092            observed.insert(pk, last_active_secs.saturating_mul(1000));
2093        }
2094    }
2095    let roles = crate::db::community::get_community_roles(&cid_hex)?;
2096    let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2097        .unwrap_or_default()
2098        .iter()
2099        .filter_map(|h| PublicKey::from_hex(h).ok())
2100        .collect();
2101    // Ban history outlives the banlist itself — see [`fold_members`]. Read from the store,
2102    // since this path never folds editions.
2103    let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2104        .unwrap_or_default()
2105        .into_iter()
2106        .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2107        .collect();
2108    fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2109}
2110
2111/// Fold the Complete Memberlist from the Guestbook plane. The proven owner is
2112/// ALWAYS a member (derived from the self-certifying community_id — no network,
2113/// so a lost/evicted genesis Join can't drop them). Observed authors — anyone
2114/// seen publishing on a channel — are folded in FORWARD-only per CORD-02 §5, so a
2115/// member whose Join was lost still counts.
2116pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2117    let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2118    // Observed authors: fold each held channel's recent authorship (real author +
2119    // newest ms), so a member who posted but whose Join was lost is still counted.
2120    let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2121    for ch in &community.channels {
2122        if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2123            for f in &page {
2124                let e = observed.entry(f.event.opened().author).or_insert(0);
2125                *e = (*e).max(f.event.opened().at_ms);
2126            }
2127        }
2128    }
2129
2130    // Fold the Control Plane roster + banlist (CORD-04) for Kick authority and the
2131    // ban subtraction. A control fetch failure degrades to owner-only authority + no
2132    // bans (fail-open on availability is safe here: a Kick still needs a real signer,
2133    // and a missed ban only fails to HIDE, never to wrongly admit authority).
2134    let authority = fetch_authority(transport, community).await;
2135    // The authorized banlist, as pubkeys (a malformed hex entry is simply dropped).
2136    let banlist: std::collections::BTreeSet<PublicKey> =
2137        authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2138    // Union the live fold's ban history with the stored marks: the fetch only reaches the
2139    // editions still in its window, and a ban that aged out is exactly the one whose
2140    // pre-ban Join would phantom.
2141    let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2142        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2143    )
2144    .unwrap_or_default()
2145    .into_iter()
2146    .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2147    .collect();
2148    for (h, at) in &authority.banned_at {
2149        if let Ok(pk) = PublicKey::from_hex(h) {
2150            let slot = banned_at.entry(pk).or_insert(0);
2151            *slot = (*slot).max(*at);
2152        }
2153    }
2154    fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2155}
2156
2157// ── Dissolution (CORD-02 §9) ─────────────────────────────────────────────────
2158
2159/// Owner dissolution / "Delete Community" (CORD-02 §9): publish the terminal
2160/// tombstone at the dissolved plane (`community_id`-derived, epoch-free, so every
2161/// past or present member resolves the same grave and a Refounding can never strand
2162/// it). The tombstone's presence IS the state; only the owner's seal counts.
2163/// Irreversible — on success the local hold is sealed read-only.
2164pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2165    let session = SessionGuard::capture();
2166    let signer = crate::signer::active_signer()?;
2167    let my_pk = me_pk()?;
2168    if community.owner()? != my_pk {
2169        return Err("only the owner can dissolve a community".to_string());
2170    }
2171    let at = now_ms() / 1000;
2172    let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2173    let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2174    if !session.is_valid() {
2175        return Err("account changed during dissolve".to_string());
2176    }
2177    // Durable broadcast: death must propagate (a rekey racing a dissolution loses).
2178    transport.publish_durable(&wrap, &community.relays).await?;
2179    crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2180    Ok(())
2181}
2182
2183/// Whether a valid owner-signed dissolution tombstone exists for this community on
2184/// its relays (CORD-02 §9). A join refuses a dead community, and a live follow seals
2185/// on sight. Fail-OPEN on a fetch error (absence of proof is not death), but any
2186/// owner-verified tombstone found is authoritative.
2187pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2188    let group = super::derive::dissolved_group_key(community.id());
2189    let query = Query {
2190        kinds: vec![stream::KIND_WRAP],
2191        authors: vec![group.pk_hex()],
2192        limit: Some(20),
2193        ..Default::default()
2194    };
2195    let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2196        return false;
2197    };
2198    wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2199}
2200
2201// ── Refounding (CORD-06 §3) ──────────────────────────────────────────────────
2202
2203/// Owner/admin Refounding (CORD-06 §3): roll the `community_root` to
2204/// cryptographically remove `removed` from a Private community (a Ban's read-cut).
2205/// Compacts the Control Plane under the new root (re-wraps each head VERBATIM — the
2206/// inner owner/actor signatures survive, so no re-authoring), rekeys the base plus
2207/// every Private channel (each sealed under the PRIOR root, D2, so a base-fork loser
2208/// can still open them), and seeds the new epoch's Guestbook snapshot. Requires BAN.
2209///
2210/// **Acquire-before-commit:** the compaction is fetched + re-sealed BEFORE any
2211/// publish, and a head we can't fetch ABORTS with ZERO published state — so a
2212/// transient miss never strands a published rekey with a half-anchored plane.
2213pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2214    let session = SessionGuard::capture();
2215    let cid = community.id();
2216    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2217    // Death wins every race: a dissolved community never re-founds (CORD-02 §9).
2218    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2219        return Err("this community has been dissolved; it cannot be re-founded".to_string());
2220    }
2221    let signer = crate::signer::active_signer()?;
2222    let my_pk = me_pk()?;
2223    // Serialize with the follow worker for the whole rotation: the commit tail
2224    // whole-row-saves, and an unserialized concurrent follow could otherwise be
2225    // rolled back (or adopt a half-published sibling of this very rotation).
2226    let lock = super::realtime::follow_lock(cid);
2227    let _guard = lock.lock().await;
2228    // Reload the FRESHEST base state: a stale caller struct would address the rotation
2229    // under a superseded root (a base fork with no heal). The community_id is
2230    // self-certifying + stable, so re-loading by it is safe.
2231    let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2232    let community = &fresh;
2233    let owner = community.owner()?;
2234
2235    // CORD-06 §Authority: a Refounding requires the BAN permission and the rotator
2236    // must strictly OUTRANK every removed target — the owner is supreme (BAN ⊂
2237    // owner). Mirrors the receive counterpart (`advance_scope::base_rotator_ok`)
2238    // and the banlist authority fold: any admin holding BAN may re-found, checked
2239    // against the folded Roster. Fail-closed — an empty/unauthorized roster leaves
2240    // only the owner able to re-found.
2241    {
2242        let owner_hex = owner.to_hex();
2243        let me_hex = my_pk.to_hex();
2244        // Persisted (last-folded) roster — the receive side is authoritative, so
2245        // this is a belt-and-suspenders gate. Fail-closed: a stale/empty roster
2246        // collapses to owner-only, which can only OVER-restrict a fresh admin whose
2247        // grant hasn't folded into their own DB (the caller's ban flow folds control
2248        // first). It can never grant authority no one has.
2249        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2250        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2251        let authorized = my_pk == owner
2252            || (!banned.contains(&me_hex)
2253                && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2254                && removed.iter().all(|t| {
2255                    roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2256                }));
2257        if !authorized {
2258            return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2259        }
2260    }
2261
2262    // Fold the current roster: the opened editions are reused for the compaction (their
2263    // seals re-wrap under the new epoch), and the roster gates which admin-authored
2264    // heads carry forward.
2265    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2266        .into_iter()
2267        .filter(|(_, f)| f.0 == community.root_epoch.0)
2268        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2269        .collect();
2270    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2271    // Page the ENTIRE control plane, not just the newest window: the compaction MUST
2272    // carry EVERY committed (floored) entity to the new epoch, so a head buried under a
2273    // flood of newer editions (100 roles + 400 grants already exceeds one page) or a
2274    // head a relay withholds can't silently drop. CORD-06 §3 mandates aborting if the
2275    // Refounder cannot fold all Control Events — a dropped Banlist would unban a member
2276    // at the new epoch a fresh joiner bootstraps.
2277    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2278    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2279    let mut oldest: Option<u64> = None;
2280    let mut until: Option<u64> = None;
2281    // Read to EXHAUSTION, not to coverage: an entity with no floor yet (a
2282    // first-ever Banlist published while we were away) is invisible to a
2283    // coverage test, so stopping there could compact it away.
2284    let mut truncated = false;
2285    for page in 0..COMPACT_MAX_PAGES {
2286        // Full: compaction re-wraps the head set it can SEE — a control
2287        // edition (a ban head) reachable only on a minority relay must not be
2288        // compacted away by a partial union.
2289        let query = Query {
2290            kinds: vec![stream::KIND_WRAP],
2291            authors: vec![current_control.pk_hex()],
2292            until,
2293            limit: Some(FOLLOW_PAGE),
2294            evidence: crate::community::transport::Evidence::Full,
2295            ..Default::default()
2296        };
2297        let wraps = transport.fetch(&query, &community.relays).await?;
2298        let mut fresh = 0usize;
2299        for w in &wraps {
2300            if !seen_wraps.insert(w.id) {
2301                continue;
2302            }
2303            fresh += 1;
2304            let at = w.created_at.as_secs();
2305            if oldest.is_none_or(|o| at < o) {
2306                oldest = Some(at);
2307            }
2308            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2309                opened.push(parsed);
2310            }
2311        }
2312        if fresh == 0 {
2313            // `until` is inclusive: a FULL page with nothing new is a same-second
2314            // wall no cursor steps past, so older editions stay unreachable. A
2315            // short page is simply the end of the plane.
2316            truncated = wraps.len() >= FOLLOW_PAGE;
2317            break;
2318        }
2319        until = oldest;
2320        if page + 1 == COMPACT_MAX_PAGES {
2321            truncated = true;
2322        }
2323    }
2324    if truncated {
2325        return Err(
2326            "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(),
2327        );
2328    }
2329
2330    let prev_epoch = community.root_epoch;
2331    let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2332    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2333    // Mint-or-REUSE the new root, keyed by (scope, new_epoch) and archived BEFORE any
2334    // publish: a retried Refounding re-delivers the SAME root at this epoch/address, so
2335    // it can't double-mint two roots a receiver's correlation dedup would collapse into
2336    // a permanent fork (CORD-06 §3 idempotency). The compaction fetch above straddled
2337    // this DB write — re-check so a mid-fetch swap can't archive into another account.
2338    if !session.is_valid() {
2339        return Err("account changed during re-founding compaction".to_string());
2340    }
2341    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2342    let new_control = control_group_key(&new_root, cid, new_epoch);
2343    let at = now_ms();
2344    let at_secs = at / 1000;
2345
2346    // ACQUIRE + COVERAGE GATE (CORD-06 §3 MUST): re-wrap the head of EVERY committed
2347    // (floored) entity under the new epoch — FLOOR-driven, so nothing silently drops,
2348    // including entities the metadata/roster folds don't touch (the invite Registry
2349    // vsk-8, whose coordinate survives the rekey per CORD-05 §5). A floor whose head
2350    // can't be folded (buried past the pager / withheld) ABORTS before any publish.
2351    use std::collections::BTreeMap;
2352    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2353    for (i, (e, _)) in opened.iter().enumerate() {
2354        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2355    }
2356    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2357    for (floor_key, floor) in &floors {
2358        // Re-wrap the AUTHORIZED head — the exact edition the persisted floor commits to
2359        // (its self_hash). The floor advances ONLY to authorized heads (author-aware fold),
2360        // so matching it is authority-correct across EVERY entity type. `fold_head`'s
2361        // version-chain TIP is author-BLIND: a member can seal a forged higher-version
2362        // edition chaining onto the floor, which the tip would carry and honest folders
2363        // then DROP as unauthorized — silently suppressing that role/grant/banlist across
2364        // the refounding. Abort if the committed head isn't served (fail-closed).
2365        let head_idx = by_eid
2366            .get(floor_key)
2367            .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2368        let Some(head_idx) = head_idx else {
2369            return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2370        };
2371        let (head_ed, head_os) = &opened[head_idx];
2372        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2373        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2374        carried.push((h, rewrapped));
2375    }
2376    if !session.is_valid() {
2377        return Err("account changed during re-founding acquire".to_string());
2378    }
2379
2380    // Recipients: the current members minus `removed`, plus me (multi-device).
2381    let members = memberlist(transport, community).await?;
2382    let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2383    let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2384    if !recipients.iter().any(|p| *p == my_pk) {
2385        recipients.push(my_pk);
2386    }
2387
2388    // Base rekey blobs (the new root to each recipient), sealed under the PRIOR root.
2389    let mut base_blobs = Vec::new();
2390    for r in &recipients {
2391        base_blobs.push(
2392            super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2393                .await
2394                .map_err(|e| e.to_string())?,
2395        );
2396    }
2397    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2398    let base_chunks =
2399        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())
2400            .await
2401            .map_err(|e| e.to_string())?;
2402
2403    // Private-channel rekeys: each mints a fresh key at its next channel-epoch, sealed
2404    // under the PRIOR root (D2). Public channels ride the base — no per-channel rekey.
2405    //
2406    // Each private channel goes only to ITS entitled set, never the base recipient
2407    // list: a Refounding that re-broadcast every private key to every member would
2408    // undo the access lists on every rotation (CORD-03).
2409    // Entitlement must come from a CURRENT roster, not the last-folded cache: the
2410    // base recipients above are a fresh network fold, and mixing the two strands
2411    // anyone granted since this client last folded — they keep a dead key and the
2412    // new epoch's rekey plane carries no blob for them. Fetched, then merged over
2413    // the cache so a role we published ourselves survives too.
2414    let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2415    {
2416        let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2417        for r in cached.roles {
2418            if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2419                roster_for_channels.roles.push(r);
2420            }
2421        }
2422        for g in cached.grants {
2423            if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2424                roster_for_channels.grants.push(g);
2425            }
2426        }
2427    }
2428    if !session.is_valid() {
2429        return Err("account changed during re-founding entitlement fetch".to_string());
2430    }
2431    let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2432    let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2433    let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2434    for ch in &community.channels {
2435        let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2436        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2437        let entitled: Vec<PublicKey> = recipients
2438            .iter()
2439            .copied()
2440            .filter(|r| {
2441                *r == my_pk
2442                    || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2443            })
2444            .collect();
2445        let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2446        // Mint-or-reuse per channel too, keyed by (channel_id, next epoch) — same
2447        // retry-idempotency as the base root. The base-rekey signing above is a bunker
2448        // round-trip; re-check before this per-channel DB write straddles it.
2449        if !session.is_valid() {
2450            return Err("account changed during re-founding channel prepare".to_string());
2451        }
2452        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)?;
2453        let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2454        let mut ch_blobs = Vec::new();
2455        for r in &entitled {
2456            ch_blobs.push(
2457                super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2458                    .await
2459                    .map_err(|e| e.to_string())?,
2460            );
2461        }
2462        let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2463        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())
2464            .await
2465            .map_err(|e| e.to_string())?;
2466        channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2467        channel_chunk_sets.push(ch_chunks);
2468    }
2469    if !session.is_valid() {
2470        return Err("account changed during re-founding prepare".to_string());
2471    }
2472
2473    // COMMIT (durable publishes only — all fetching is done). Base rekey first
2474    // (delivers the new root), then channel rekeys, then the compacted control.
2475    for c in &base_chunks {
2476        transport.publish_durable(c, &community.relays).await?;
2477    }
2478    for set in &channel_chunk_sets {
2479        for c in set {
2480            transport.publish_durable(c, &community.relays).await?;
2481        }
2482    }
2483    for (_, wrap) in &carried {
2484        transport.publish_durable(wrap, &community.relays).await?;
2485    }
2486    // Guestbook snapshot at the new epoch — best-effort (a Refounding succeeds without
2487    // it; an omitted member heals by publishing their own Join).
2488    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2489    let snap_id = crate::community::random_32();
2490    for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2491        if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2492            let _ = transport.publish(&wrap, &community.relays).await;
2493        }
2494    }
2495
2496    // COMMIT locally, only now that the new root + compacted plane are on relays.
2497    if !session.is_valid() {
2498        return Err("account changed during re-founding commit".to_string());
2499    }
2500    if crate::db::community::community_protocol(cid)?.is_none() {
2501        return Ok(community.clone()); // left/deleted mid-rotation — don't resurrect.
2502    }
2503    // Save the new root/epoch + rekeyed channel keys in ONE tx FIRST, so a crash can
2504    // never leave the base root advanced while the channel keys lag (which would
2505    // re-derive the channel rekey address under the wrong root and orphan them).
2506    let mut updated = community.clone();
2507    updated.community_root = new_root;
2508    updated.root_epoch = new_epoch;
2509    for (id, key, ep) in &channel_updates {
2510        if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2511            c.key = Some(*key);
2512            c.epoch = *ep;
2513        }
2514    }
2515    crate::db::community::save_community_v2(&updated)?;
2516    // Archive the new epoch key + confirm the monotonic base head (the root was already
2517    // archived by mint_or_reuse, so this is idempotent). Record the carried heads at
2518    // the NEW epoch; if a crash skips this, the epoch-filtered floors bootstrap the
2519    // compacted control on the next follow, so they self-heal.
2520    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2521    for (h, _) in &carried {
2522        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2523    }
2524    // Re-subscribe NOW: the rotation changed every plane author, and the live sub
2525    // still carries the OLD epoch's set. Members adopt via the follow worker
2526    // (which refreshes); the REFOUNDER has no such path — without this, the very
2527    // client that performed the ban goes deaf to the new epoch (a rejoin lands on
2528    // the relays and never arrives live).
2529    if let Some(client) = crate::state::nostr_client() {
2530        super::realtime::refresh_subscription(&client).await;
2531    }
2532    // Refresh any live public links so their bundles carry the NEW root behind the
2533    // same URL (a link shared once survives the rotation, CORD-05 §2). Idempotent,
2534    // so retry a transient failure — a stranded link lands a new joiner on the dead
2535    // pre-refound epoch, and there's no other trigger to heal it before the next
2536    // refounding. A persistent failure is logged (refound already succeeded).
2537    for attempt in 0..3u8 {
2538        match refresh_public_links(transport, &updated).await {
2539            Ok(()) => break,
2540            Err(_) if !session.is_valid() => break, // swapped — stop touching this account
2541            Err(e) if attempt == 2 => {
2542                crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2543            }
2544            Err(_) => continue,
2545        }
2546    }
2547    Ok(updated)
2548}
2549
2550/// BIRTH refound (§migration Phase 1.4): roll a freshly-minted migration twin from epoch 0
2551/// to epoch 1 so it can carry an owner-signed Guestbook SNAPSHOT of the full v1 memberlist —
2552/// genesis (epoch 0) has no snapshot authority (`fold_members` gates on `root_epoch > 0`), so
2553/// this is the ONLY way to seed a roster every honest client folds. UNLIKE [`refound_community`]
2554/// the two sets are DECOUPLED:
2555///
2556/// - **Rekey recipients = {owner} ONLY.** Members do NOT get the epoch-1 root via birth blobs
2557///   — they get it from the migration carrier's `m` (sealed AFTER this returns). Keeping the
2558///   set at {owner} also dodges the 120-blob rotation cap for large communities.
2559/// - **Snapshot members = the EXPLICIT full v1 list** (`snapshot_members`, display/roster only,
2560///   no keys). Chunked at SNAPSHOT_CHUNK (400)/rumor, no cap — a 10k-member community seeds fine.
2561///
2562/// The SAFEST refound possible: the owner authored 100% of the control plane seconds ago and
2563/// holds every edition locally, so the fold-all-or-abort discipline is trivially met (a flaky
2564/// relay just fires the abort → the wizard retries). Returns the epoch-1 community.
2565pub async fn refound_at_birth<T: Transport + ?Sized>(
2566    transport: &T,
2567    community: &CommunityV2,
2568    snapshot_members: &[PublicKey],
2569) -> Result<CommunityV2, String> {
2570    let session = SessionGuard::capture();
2571    let cid = community.id();
2572    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2573    // Death wins every race: a dissolved community never re-founds (CORD-02 §9, parity with
2574    // refound_community). A migration twin should never be dissolved mid-build, but fail-closed.
2575    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2576        return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2577    }
2578    let signer = crate::signer::active_signer()?;
2579    let my_pk = me_pk()?;
2580    if my_pk != community.owner()? {
2581        return Err("only the owner can birth-refound the migration twin".to_string());
2582    }
2583    let lock = super::realtime::follow_lock(cid);
2584    let _guard = lock.lock().await;
2585    let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2586    // RESUME IDEMPOTENCE: if the refound already committed locally (epoch 1) but crashed
2587    // before its ledger write, the wizard re-calls this. The epoch advance + compaction only
2588    // commit AFTER the snapshot published durably + verified back (below), so an epoch-1 twin
2589    // means the snapshot already landed and is readable — return it. A twin past epoch 1 is
2590    // unexpected (nothing else rotates a mid-migration twin).
2591    if community.root_epoch.0 == 1 {
2592        return Ok(community);
2593    }
2594    if community.root_epoch.0 != 0 {
2595        return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2596    }
2597    let community = &community;
2598
2599    // Compact the epoch-0 control plane onto epoch 1: re-wrap the committed head of every
2600    // floored entity VERBATIM (inner owner/admin signatures survive). The owner holds every
2601    // edition locally (authored seconds ago), so this fold-all-or-abort is trivially met.
2602    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2603        .into_iter()
2604        .filter(|(_, f)| f.0 == community.root_epoch.0)
2605        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2606        .collect();
2607    let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2608    let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2609    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2610    let mut oldest: Option<u64> = None;
2611    let mut until: Option<u64> = None;
2612    // Exhaustion, not coverage — see the sibling read in `refound_community`.
2613    let mut truncated = false;
2614    for page in 0..COMPACT_MAX_PAGES {
2615        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() };
2616        let wraps = transport.fetch(&query, &community.relays).await?;
2617        let mut fresh = 0usize;
2618        for w in &wraps {
2619            if !seen_wraps.insert(w.id) { continue; }
2620            fresh += 1;
2621            let at = w.created_at.as_secs();
2622            if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2623            if let Ok(parsed) = control::open_control_edition(w, &current_control) {
2624                opened.push(parsed);
2625            }
2626        }
2627        if fresh == 0 {
2628            truncated = wraps.len() >= FOLLOW_PAGE;
2629            break;
2630        }
2631        until = oldest;
2632        if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2633    }
2634    if truncated {
2635        return Err(
2636            "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(),
2637        );
2638    }
2639
2640    let prev_epoch = community.root_epoch; // 0
2641    let new_epoch = Epoch(1);
2642    let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2643    if !session.is_valid() {
2644        return Err("account changed during birth-refound compaction".to_string());
2645    }
2646    let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2647    let new_control = control_group_key(&new_root, cid, new_epoch);
2648    let at = now_ms();
2649    let at_secs = at / 1000;
2650
2651    use std::collections::BTreeMap;
2652    let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2653    for (i, (e, _)) in opened.iter().enumerate() {
2654        by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2655    }
2656    let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2657    for (floor_key, floor) in &floors {
2658        let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2659        let Some(head_idx) = head_idx else {
2660            return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2661        };
2662        let (head_ed, head_os) = &opened[head_idx];
2663        let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2664        let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2665        carried.push((h, rewrapped));
2666    }
2667    if !session.is_valid() {
2668        return Err("account changed during birth-refound acquire".to_string());
2669    }
2670
2671    // Base rekey: the epoch-1 root to the OWNER ONLY (members key up via the carrier's `m`).
2672    let base_blobs = vec![
2673        super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2674            .await
2675            .map_err(|e| e.to_string())?,
2676    ];
2677    let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2678    let base_chunks =
2679        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())
2680            .await
2681            .map_err(|e| e.to_string())?;
2682    if !session.is_valid() {
2683        return Err("account changed during birth-refound prepare".to_string());
2684    }
2685
2686    // COMMIT to the wire: base rekey (owner's new root), then the compacted control.
2687    for c in &base_chunks {
2688        transport.publish_durable(c, &community.relays).await?;
2689    }
2690    for (_, wrap) in &carried {
2691        transport.publish_durable(wrap, &community.relays).await?;
2692    }
2693    // The Guestbook SNAPSHOT — the WHOLE POINT of the birth refound, so publish it DURABLY
2694    // and FAIL the refound if any chunk doesn't land. Unlike `refound_community` (where
2695    // live members heal via their own Join if a chunk drops), a seeded-never-landed member
2696    // CANNOT heal — omitted → absent from `memberlist()` → excluded from every future rotation
2697    // → permanently stranded. So the snapshot is load-bearing, not best-effort. The publishes
2698    // precede the local commit, so a `?`-abort leaves epoch 0 and a retry re-runs idempotently
2699    // (mint_or_reuse gives the same epoch-1 root; snapshot chunks coalesce commutatively).
2700    let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2701    let snap_id = crate::community::random_32();
2702    let snapshot_wraps: Vec<Event> = {
2703        let mut out = Vec::new();
2704        for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2705            let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2706                .await
2707                .map_err(|e| format!("seal birth snapshot: {e}"))?;
2708            out.push(wrap);
2709        }
2710        out
2711    };
2712    for wrap in &snapshot_wraps {
2713        transport.publish_durable(wrap, &community.relays).await?;
2714    }
2715    // Verify-back (design §4 Phase 1.5): fetch the snapshot at the new epoch and confirm every
2716    // seeded member folds, before we commit locally. A relay that ACKed a durable publish but
2717    // won't serve it back (or a partial landing) aborts here with ZERO local state — the retry
2718    // re-publishes. A seed that is (legitimately) in the folded banlist is EXPECTED to be
2719    // absent from the memberlist (`memberlist` subtracts the banlist, so requiring a
2720    // banned seed to "fold" would wedge the retry forever) — so subtract the wire-folded
2721    // banlist from the expected set. The real caller never seeds a banned member, but the
2722    // arbitrary-`snapshot_members` API must not be able to wedge on one.
2723    let verify_view = {
2724        let mut v = community.clone();
2725        v.community_root = new_root;
2726        v.root_epoch = new_epoch;
2727        v
2728    };
2729    let expected: Vec<PublicKey> = {
2730        let banlist = fetch_authority(transport, &verify_view).await.banned;
2731        snapshot_members.iter().copied()
2732            .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2733            .collect()
2734    };
2735    if !expected.is_empty() {
2736        let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2737        let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2738        if missing > 0 {
2739            return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2740        }
2741    }
2742
2743    // COMMIT locally, only now that the new root + compacted plane + snapshot are on relays.
2744    if !session.is_valid() {
2745        return Err("account changed during birth-refound commit".to_string());
2746    }
2747    if crate::db::community::community_protocol(cid)?.is_none() {
2748        return Ok(community.clone());
2749    }
2750    let mut updated = community.clone();
2751    updated.community_root = new_root;
2752    updated.root_epoch = new_epoch;
2753    crate::db::community::save_community_v2(&updated)?;
2754    crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2755    for (h, _) in &carried {
2756        crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2757    }
2758    Ok(updated)
2759}
2760
2761/// Mint a fresh 32-byte rotation key for `(scope, new_epoch)`, or REUSE the one
2762/// already archived from a prior (aborted) attempt — so a retried Refounding re-
2763/// delivers the SAME key at the same epoch/address instead of double-minting two roots
2764/// a receiver's correlation dedup would collapse into a permanent fork (CORD-06 §3
2765/// idempotency). Archived BEFORE the first publish; `scope` is the all-zero server-root
2766/// sentinel for a base rotation, else the channel_id hex.
2767fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2768    if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2769        return Ok(existing);
2770    }
2771    let fresh = crate::community::random_32();
2772    crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2773    Ok(fresh)
2774}
2775
2776// ── The Community List (kind 13302, CORD-02 §8) ──────────────────────────────
2777
2778/// This community's MEMBERSHIP subset for the 13302 list (CORD-02 §8): never the
2779/// icon (a rehydrating device folds it from the Control Plane), never the link
2780/// fields. Only PRIVATE channel keys ride — public channels derive from the root.
2781fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2782    let hex = crate::simd::hex::bytes_to_hex_32;
2783    let channels = community
2784        .channels
2785        .iter()
2786        .filter(|c| c.private)
2787        // Keyed channels ONLY. A keyless entry is readable by this build but is
2788        // rejected outright by shipped ones (their `key` is a required String),
2789        // so emitting one would strand every older client on a stale list.
2790        .filter_map(|c| {
2791            c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2792        })
2793        .collect();
2794    super::list::JoinMaterial {
2795        community_id: hex(&community.identity.community_id.0),
2796        owner: hex(&community.identity.owner_xonly),
2797        owner_salt: hex(&community.identity.owner_salt),
2798        community_root: hex(&community.community_root),
2799        root_epoch: community.root_epoch.0,
2800        channels,
2801        relays: community.relays.clone(),
2802        name: community.name.clone(),
2803        extra: Default::default(),
2804    }
2805}
2806
2807/// Rebuild an invite bundle from list join material, for a cross-device rehydrate
2808/// (the material IS the membership subset of a bundle). The owner root is still
2809/// verified over the network before the community is trusted (accept_bundle).
2810fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2811    // A keyless listing records that the channel EXISTS, not a grant — there is
2812    // nothing to seat, and it keys up when access is granted.
2813    let channels = jm
2814        .channels
2815        .iter()
2816        .filter_map(|c| {
2817            c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2818        })
2819        .collect();
2820    CommunityInvite {
2821        community_id: jm.community_id.clone(),
2822        owner: jm.owner.clone(),
2823        owner_salt: jm.owner_salt.clone(),
2824        community_root: jm.community_root.clone(),
2825        root_epoch: jm.root_epoch,
2826        channels,
2827        relays: jm.relays.clone(),
2828        name: jm.name.clone(),
2829        icon: None,
2830        expires_at: None,
2831        creator_npub: None,
2832        label: None,
2833        extra: Default::default(),
2834    }
2835}
2836
2837/// The union of every held v2 community's relays — where this account's 13302 list
2838/// lives (a fresh device that opens any held community reaches the same set).
2839fn held_v2_relays() -> Vec<String> {
2840    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2841    if let Ok(ids) = crate::db::community::list_community_ids() {
2842        for id in ids {
2843            if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2844                if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2845                    set.extend(c.relays);
2846                }
2847            }
2848        }
2849    }
2850    set.into_iter().collect()
2851}
2852
2853/// Fetch this account's own 13302 Community List from `relays` (the newest wins;
2854/// a decrypt/parse failure is "no news", never a clobber of the local mirror).
2855/// Fetch this account's newest 13302 list. `Err` = the transport FAILED (a caller
2856/// must NOT drive a replaceable-event write from a failed read — it would clobber
2857/// the live list); `Ok(None)` = genuinely no list yet; `Ok(Some)` = the list.
2858async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2859    let signer = crate::signer::active_signer()?;
2860    let my_pk = me_pk()?;
2861    let query = Query {
2862        kinds: vec![super::kind::COMMUNITY_LIST],
2863        authors: vec![my_pk.to_hex()],
2864        limit: Some(4),
2865        ..Default::default()
2866    };
2867    let events = transport.fetch(&query, relays).await?;
2868    let seen = events.len();
2869    // Which copy won matters: relays disagree (one may hold a stale replaceable),
2870    // and a list near the NIP-44 ceiling stops accepting joins — both are invisible
2871    // without saying so.
2872    let mut undecryptable = 0usize;
2873    let mut unreadable: Option<(u64, String, usize, String)> = None;
2874    let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2875    for e in events {
2876        let at = e.created_at.as_secs();
2877        let id_hex = e.id.to_hex();
2878        let content_len = e.content.len();
2879        match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2880            Ok(l) => {
2881                if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2882                    best = Some((at, id_hex, l));
2883                }
2884            }
2885            Err(err) => {
2886                undecryptable += 1;
2887                if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2888                    unreadable = Some((at, id_hex, content_len, err.to_string()));
2889                }
2890            }
2891        }
2892    }
2893    // Only the case that costs data is worth a warning: a copy we could not read
2894    // that was NEWER than the one we settled for. That silently pins the account
2895    // to stale membership, and the parse error is the only clue to why.
2896    if let Some((at, id, len, err)) = &unreadable {
2897        if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2898            crate::log_net_fail!(
2899                "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2900                &id[..8]
2901            );
2902        }
2903    }
2904    if let Some((at, id, l)) = &best {
2905        let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2906        crate::log_debug!(
2907            "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2908            &id[..8],
2909            super::stream::NIP44_MAX_PLAINTEXT
2910        );
2911    }
2912    Ok(best.map(|(_, _, l)| l))
2913}
2914
2915/// Rebuild this account's 13302 from its held v2 communities, MERGE with the remote
2916/// copy (preserving tombstones, other-device entries, unknown fields), and publish.
2917/// `just_joined` is the community THIS call is recording a create/join for — the
2918/// ONLY community whose entry is (re)stamped `now`, so it beats any prior tombstone
2919/// (a deliberate re-join resurrects). Every OTHER held community that the remote
2920/// has tombstoned is left tombstoned (a sibling device's leave is NOT undone just
2921/// because we joined something else — the W1 resurrection hole). Idempotent;
2922/// best-effort — a list-publish failure never fails the membership change itself.
2923/// Returns `Ok(true)` when the list was PUBLISHED, `Ok(false)` when the attempt was
2924/// skipped without failing the caller (a failed remote fetch — see below). Callers that
2925/// need the membership to actually land use [`republish_community_list_durable`].
2926pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2927    let session = SessionGuard::capture();
2928    let signer = crate::signer::active_signer()?;
2929    let my_pk = me_pk()?;
2930    let relays = held_v2_relays();
2931    if relays.is_empty() {
2932        return Ok(false); // nothing held → nothing to sync
2933    }
2934    // A FAILED remote fetch must not drive this replaceable-event write: publishing
2935    // a list built without the remote seeds would drop older-epoch backfill anchors
2936    // and re-stamp add-times (the W2 seed-regression + a resurrection window).
2937    let remote = match fetch_community_list(transport, &relays).await {
2938        Ok(r) => r.unwrap_or_default(),
2939        Err(e) => {
2940            // SILENT-SKIP HAZARD: bailing is correct (publishing a list built without the
2941            // remote seeds drops backfill anchors), but the membership this call was meant
2942            // to record is now simply unrecorded. A join that lands here leaves a community
2943            // held locally with no list entry — and if it also carries an older tombstone,
2944            // nothing ever out-ranks it again. Say so loudly; `Ok(())` keeps it non-fatal.
2945            crate::log_warn!(
2946                "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2947                e,
2948                just_joined
2949                    .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2950                    .unwrap_or_default()
2951            );
2952            return Ok(false);
2953        }
2954    };
2955    let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2956    let now = now_ms();
2957    let mut local = super::list::CommunityList::default();
2958    for id in crate::db::community::list_community_ids()? {
2959        if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2960            continue;
2961        }
2962        let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2963        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2964        let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2965        // A held community the remote has tombstoned (a sibling device left it) that
2966        // we are NOT currently (re)joining stays LEFT — don't re-add it, or joining a
2967        // different community would silently undo the leave everywhere.
2968        //
2969        // UNLESS our hold POST-DATES the removal. A rejoin whose membership never
2970        // reached the list (this publish is best-effort — a failed remote fetch
2971        // silently skips it) leaves a tombstone with no entry, and nothing can ever
2972        // out-rank it again: every boot the list sync reads "removed", tears the
2973        // community down, the rejoin re-adds it, and it loops forever. Our own hold
2974        // is first-hand evidence of membership, so let it settle the tie by the same
2975        // add-vs-remove rule the list already uses everywhere else.
2976        let tombstoned_at = remote
2977            .tombstones
2978            .iter()
2979            .find(|t| t.community_id == cid_hex)
2980            .map(|t| t.removed_at)
2981            .unwrap_or(0);
2982        let held_since = c.created_at_ms;
2983        if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2984            crate::log_warn!(
2985                "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2986                &cid_hex[..8], tombstoned_at, held_since
2987            );
2988            continue;
2989        }
2990        // Keep an already-live entry's add time (no churn); the joined community (or a
2991        // genuinely-new one) stamps `now` so a re-join beats a stale tombstone. A hold
2992        // that outlived a tombstone re-asserts itself at its own join time, which is
2993        // already newer than the removal.
2994        let added_at = if remote.is_live(&cid_hex) && !is_join {
2995            remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2996        } else if !is_join && tombstoned_at > 0 {
2997            held_since
2998        } else {
2999            now
3000        };
3001        let jm = join_material(&c);
3002        local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
3003    }
3004    let merged = remote.merge(&local);
3005    merged.assert_fits().map_err(|e| e.to_string())?;
3006    let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
3007    if !session.is_valid() {
3008        return Err("account changed during community-list publish".to_string());
3009    }
3010    if let Err(e) = transport.publish(&event, &relays).await {
3011        crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
3012        return Err(e);
3013    }
3014    Ok(true)
3015}
3016
3017/// Retry budget for [`republish_community_list_durable`]. An unrecorded membership is
3018/// invisible to the user and self-heals only on their NEXT join, so ride out a relay
3019/// blip rather than a single shot. Bounded: a permanently dead relay set gives up
3020/// instead of spinning.
3021const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3022
3023/// Record a membership across devices DURABLY: retry in the background until the list
3024/// actually lands.
3025///
3026/// [`republish_community_list`] must never fail a join, and it deliberately publishes
3027/// NOTHING when the remote fetch fails (a list built without the remote seeds would drop
3028/// other devices' entries). One shot at that means a relay blip during a join leaves the
3029/// membership unrecorded until the user happens to join something else — and if a stale
3030/// tombstone out-ranks it, the community is stranded until a manual leave+rejoin.
3031///
3032/// Non-blocking. Skipped entirely without a live client (headless/unit tests drive the
3033/// generic fn directly). The `SessionGuard` is captured BEFORE the spawn and re-checked
3034/// before every attempt, so an account swap mid-backoff can't publish A's list from B.
3035pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3036    if crate::state::nostr_client().is_none() {
3037        return;
3038    }
3039    let session = SessionGuard::capture();
3040    tokio::spawn(async move {
3041        for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3042            if !session.is_valid() {
3043                return;
3044            }
3045            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3046            match republish_community_list(&transport, just_joined.as_ref()).await {
3047                Ok(true) => {
3048                    if attempt > 0 {
3049                        crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3050                    }
3051                    return;
3052                }
3053                Ok(false) => {} // skipped (remote fetch failed) — already logged; retry
3054                Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3055            }
3056            tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3057        }
3058        crate::log_warn!(
3059            "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3060            LIST_REPUBLISH_BACKOFF_SECS.len()
3061        );
3062    });
3063}
3064
3065/// Record a permanent leave tombstone for `community_id` in the 13302, published to
3066/// `relays` (the leaving community's own, since it's about to be deleted locally).
3067async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3068    let signer = crate::signer::active_signer()?;
3069    let my_pk = me_pk()?;
3070    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3071    // A failed fetch here would drop other communities' entries (only the
3072    // tombstone would survive); preserve them by bailing — the leave re-records
3073    // on the next attempt, and the local teardown already happened.
3074    let mut doc = match fetch_community_list(transport, relays).await {
3075        Ok(d) => d.unwrap_or_default(),
3076        Err(e) => return Err(e),
3077    };
3078    let now = now_ms();
3079    doc.tombstones.retain(|t| t.community_id != cid_hex);
3080    doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3081    doc.assert_fits().map_err(|e| e.to_string())?;
3082    let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3083    transport.publish(&event, relays).await
3084}
3085
3086/// Sync memberships from the 13302 across devices: fetch this account's list from
3087/// `bootstrap_relays` (its held communities' relays plus any caller-supplied set for
3088/// a fresh device), and JOIN every live entry not already held — reconstructing the
3089/// community from its join material and re-verifying the owner root. Returns the
3090/// newly-rehydrated communities (so the caller can subscribe + notify).
3091/// What one Community-List sync changed locally.
3092pub struct ListSyncOutcome {
3093    /// Communities newly adopted from the list (already persisted + chat-registered).
3094    pub joined: Vec<CommunityV2>,
3095    /// Communities a sibling device LEFT, as `(community_id_hex, channel_id_hexes)`.
3096    ///
3097    /// The rows are already gone here, so the ids are captured BEFORE deletion: the caller
3098    /// still has to finish the local teardown (chat rows, STATE, the live subscription),
3099    /// and it can't look them up afterwards. Deleting the community while leaving its chat
3100    /// row behind is what produces a ghost "0 Members" room pointing at nothing.
3101    pub removed: Vec<(String, Vec<String>)>,
3102}
3103
3104pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3105    let session = SessionGuard::capture();
3106    let mut relays = held_v2_relays();
3107    relays.extend(bootstrap_relays.iter().cloned());
3108    relays.sort();
3109    relays.dedup();
3110    if relays.is_empty() {
3111        return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3112    }
3113    // A cross-device sync that finds nothing is indistinguishable from one that
3114    // never ran, so every exit says why — this path is only ever debugged after
3115    // the fact, from a user's log.
3116    let list = match fetch_community_list(transport, &relays).await {
3117        Ok(Some(l)) => {
3118            crate::log_debug!(
3119                "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3120                l.entries.len(),
3121                l.tombstones.len(),
3122                relays.len()
3123            );
3124            l
3125        }
3126        Ok(None) => {
3127            // Transient by nature: boot runs many concurrent passes and a relay that
3128            // times out under that load returns nothing. Only persistent absence
3129            // matters, and that shows up as "adopted nothing" anyway.
3130            crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3131            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3132        }
3133        Err(e) => {
3134            crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3135            return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3136        }
3137    };
3138    // Receive-side teardown (the counterpart to the republish tombstone guard):
3139    // a community this device still holds but the synced list shows TOMBSTONED (a
3140    // sibling device left it) and NOT live gets torn down here, so a leave on one
3141    // device propagates to the others. A re-join would have re-added it live
3142    // (beating the tombstone), so is_live short-circuits the honest case.
3143    let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3144    for t in &list.tombstones {
3145        if list.is_live(&t.community_id) {
3146            continue;
3147        }
3148        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3149        let id = crate::community::CommunityId(cid);
3150        let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3151            continue; // not held — nothing to tear down
3152        };
3153        // `is_live` above assumes a rejoin re-added an entry, but recording that entry is
3154        // best-effort: a relay blip at join time leaves the tombstone unopposed forever, and
3155        // this would then delete the community on every sync. So let the LOCAL hold break the
3156        // tie too — a hold created after the removal IS the rejoin, whether or not its entry
3157        // ever reached the list. Same rule the v1 sweep uses.
3158        if held.created_at_ms > t.removed_at {
3159            crate::log_warn!(
3160                "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3161                &t.community_id[..8], t.removed_at, held.created_at_ms
3162            );
3163            continue;
3164        }
3165        if !session.is_valid() {
3166            return Err("account changed during community-list sync".to_string());
3167        }
3168        let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3169        let _ = crate::db::community::delete_community(&t.community_id);
3170        removed.push((t.community_id.clone(), channel_ids));
3171    }
3172    let mut joined = Vec::new();
3173    for entry in list.live_entries() {
3174        let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3175        // Held under ANY protocol, not just v2. A protocol-scoped check re-adopts an
3176        // id we already hold as v1: verification correctly fails (there is no v2
3177        // community at that coordinate), and the entry is retried on EVERY sync pass
3178        // forever — a permanent warning flood plus a wasted multi-relay walk each time.
3179        if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
3180            continue; // already held
3181        }
3182        if !session.is_valid() {
3183            return Err("account changed during community-list sync".to_string());
3184        }
3185        // The material IS a bundle; accept_bundle re-verifies the owner root, saves,
3186        // and seeds floors. NO Guestbook Join: this device is receiving keys the
3187        // account already holds elsewhere — the membership was announced when it
3188        // actually joined, and a key sync is not a membership event.
3189        let bundle = material_to_invite(&entry.current);
3190        match accept_bundle(transport, &session, &bundle, None, false).await {
3191            Ok(community) => joined.push(community),
3192            // A listed-but-unadoptable entry is the failure mode that reads as
3193            // "cross-device sync is broken": the community never appears and any
3194            // parked invite for it is never retired.
3195            Err(e) => crate::log_net_fail!(
3196                "[CommunityList] {} is listed but adoption failed: {e}",
3197                &entry.community_id[..entry.community_id.len().min(8)]
3198            ),
3199        }
3200    }
3201    Ok(ListSyncOutcome { joined, removed })
3202}
3203
3204// ── Control edition authoring (CORD-04 roles / CORD-02 §6 / CORD-03 §2) ──────
3205
3206/// Publish one control edition (a role, grant, banlist, community-metadata, or
3207/// channel-metadata edit) at the next version for its entity, chaining `prev` from
3208/// our held head, and advance our local floor. Authority is enforced by every
3209/// reader's roster fold (CORD-04 §5: authority is rejection, not prevention), so this
3210/// requires only a valid local signer; a well-behaved client checks its own rank
3211/// first, but a reader drops an unauthorized edition regardless.
3212/// This actor's authority citation for a control edition (CORD-04 §5): the head
3213/// of their OWN Grant entity, pinned by coordinate + version + edition hash.
3214///
3215/// A SYNC FLOOR, not a verdict — a verifier refuses to act until it has synced
3216/// at least this Grant, then resolves rank against its CURRENT roster, so a
3217/// demoted admin is never grandfathered by an old-but-once-valid citation.
3218///
3219/// `None` for the owner (supreme, rank comes from the community id) and `None`
3220/// when no Grant head is held — an actor who cannot cite has no rank to claim,
3221/// and the edition is dropped by a conforming reader either way.
3222/// The verify half of [`my_authority_citation`] (CORD-04 §5): does the actor's
3223/// cited Grant prove authority we have actually SYNCED? The owner is supreme and
3224/// cites nothing. A non-owner MUST cite, and we must hold that Grant at ≥ the
3225/// cited version with the cited hash at the tip — else fail closed, because
3226/// honoring an action whose authority we can't confirm is exactly how a demoted
3227/// moderator keeps moderating.
3228///
3229/// Completeness only: the permission + outrank is the separate roster check, so a
3230/// since-demoted actor is refused there (refuse-superseded). An action citing a
3231/// version we haven't synced parks and is re-judged on the next roster sync — the
3232/// sync path can't escalate to a blocking fetch.
3233pub(super) fn citation_is_synced(
3234    cid_hex: &str,
3235    owner_hex: &str,
3236    actor_hex: &str,
3237    citation: Option<&crate::community::edition::AuthorityCitation>,
3238) -> bool {
3239    if owner_hex == actor_hex {
3240        return true;
3241    }
3242    if citation.is_none() {
3243        return false;
3244    }
3245    let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3246    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3247    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3248        &crate::community::CommunityId(cid_bytes),
3249        &actor_bytes,
3250    ));
3251    let head: Vec<crate::community::roster::EntityHead> =
3252        crate::db::community::get_edition_head(cid_hex, &grant_hex)
3253            .ok()
3254            .flatten()
3255            .map(|(version, self_hash)| crate::community::roster::EntityHead {
3256                entity_hex: grant_hex.clone(),
3257                version,
3258                self_hash,
3259                inner_id: [0u8; 32],
3260                citation: None,
3261            })
3262            .into_iter()
3263            .collect();
3264    crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3265}
3266
3267/// [`my_authority_citation`], but refusing to emit an action every reader will
3268/// drop (CORD-04 §5: an uncited non-owner action is not honored).
3269///
3270/// The citation is built from PERSISTED heads, which only `follow_control` writes
3271/// — so an admin who hasn't folded yet (just promoted, or freshly restored) would
3272/// otherwise publish uncited and have the action silently vanish on every client,
3273/// with nothing shown locally. Failing here turns that into one retryable error.
3274fn required_authority_citation(
3275    community: &CommunityV2,
3276    actor: &PublicKey,
3277) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3278    if community.owner().ok().as_ref() == Some(actor) {
3279        return Ok(None); // supreme, cites nothing
3280    }
3281    my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3282        "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3283    })
3284}
3285
3286fn my_authority_citation(
3287    community: &CommunityV2,
3288    actor: &PublicKey,
3289) -> Option<crate::community::edition::AuthorityCitation> {
3290    if community.owner().ok().as_ref() == Some(actor) {
3291        return None;
3292    }
3293    let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3294    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3295    let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3296    crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3297        .ok()
3298        .flatten()
3299        .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3300}
3301
3302/// Refuse a root-derived write whose in-hand struct predates a rotation.
3303///
3304/// A Ban's refound buries the old root while the caller's `CommunityV2` still
3305/// points at it; publishing there lands on a plane nobody folds — the action
3306/// "succeeds" and silently never happened (an unban that doesn't unban, an
3307/// invite that strands its joiner on a dead epoch). Failing loudly instead lets
3308/// the caller reload and retry against the living root.
3309fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3310    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3311    match crate::db::community::get_server_root_epoch(&cid_hex)? {
3312        Some(held) if held != community.root_epoch.0 => Err(format!(
3313            "the community re-founded mid-action (epoch {} -> {held}); retry",
3314            community.root_epoch.0
3315        )),
3316        _ => Ok(()), // no row = a not-yet-persisted create; nothing newer to defer to
3317    }
3318}
3319
3320async fn publish_control_edition<T: Transport + ?Sized>(
3321    transport: &T,
3322    community: &CommunityV2,
3323    session: &SessionGuard,
3324    vsk: &str,
3325    entity_id: &[u8; 32],
3326    content: &str,
3327) -> Result<(), String> {
3328    assert_current_root(community)?;
3329    let signer = crate::signer::active_signer()?;
3330    let my_pk = me_pk()?;
3331    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3332    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3333    let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3334    let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3335        Some((v, h)) => (v + 1, Some(h)),
3336        None => (1, None),
3337    };
3338    // CORD-04 §5: a non-owner names the exact Grant edition it claims its rank
3339    // under. Computed here rather than passed in — the citation is a property of
3340    // WHO IS ACTING, identical for every entity kind, so deciding it per call
3341    // site is nine chances to forget (and nine were, silently: every site passed
3342    // None). The owner cites nothing; their rank is the community id itself.
3343    let citation = required_authority_citation(community, &my_pk)?;
3344    let at = now_ms() / 1000;
3345    let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3346    let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3347    if !session.is_valid() {
3348        return Err("account changed before control publish".to_string());
3349    }
3350    transport.publish(&wrap, &community.relays).await?;
3351    // Advance our own floor so a follow-up edit chains from this head and refuse-
3352    // downgrade holds; open our own wrap to recover the self_hash + inner_id.
3353    // Re-check the session AFTER the publish await: a swap mid-publish means the
3354    // pool now points at another account's DB — skipping is safe (the next own
3355    // edit rebuilds the same head from the relay's copy).
3356    if !session.is_valid() {
3357        return Ok(());
3358    }
3359    if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3360        crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3361    }
3362    Ok(())
3363}
3364
3365/// Merge our OWN just-published Role/Grant into the locally stored roster.
3366///
3367/// v2 persists the roster only inside `follow_control`, so a role or grant we
3368/// just published is invisible to every sync local read (entitlement, capability
3369/// gates, the next grant) until the next fold. This writes what we are already
3370/// authorized to have written; the next fold recomputes from the plane and
3371/// converges. Mirrors the fold's own write, so the stored `roles_at` is left
3372/// alone — a real edition always outranks this optimistic merge.
3373fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3374    let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3375    if let Some(r) = role {
3376        match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3377            Some(slot) => *slot = r.clone(),
3378            None => roster.roles.push(r.clone()),
3379        }
3380    }
3381    if let Some(g) = grant {
3382        match roster.grants.iter_mut().find(|x| x.member == g.member) {
3383            Some(slot) => *slot = g.clone(),
3384            None => roster.grants.push(g.clone()),
3385        }
3386    }
3387    let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3388    if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3389        crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3390    }
3391}
3392
3393/// Create or edit a Role (vsk 1, CORD-04 §2). `role.role_id` is the coordinate; a
3394/// rename or permission change is a versioned edit of the same id. Gated on the
3395/// reader side by `MANAGE_ROLES` + outrank.
3396pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3397    let session = SessionGuard::capture();
3398    super::roles::validate_role(role)?;
3399    let content = super::roles::role_content_json(role)?;
3400    let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3401    publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3402}
3403
3404/// Grant or revoke a member's Roles (vsk 3, CORD-04 §2). Empty `role_ids` is a
3405/// revoke. Gated on the reader side by `MANAGE_ROLES` + outrank of every role + the
3406/// member.
3407pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3408    let session = SessionGuard::capture();
3409    let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3410    let content = super::roles::grant_content_json(&grant)?;
3411    let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3412    publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3413}
3414
3415/// The community's @admin role id: the folded Server-scope ADMIN_ALL role when one
3416/// exists, else (with `create_if_missing`) a DETERMINISTIC mint — the same id on
3417/// every device, so concurrent grants converge as editions of ONE entity instead
3418/// of forking two Admin roles.
3419pub async fn ensure_admin_role<T: Transport + ?Sized>(
3420    transport: &T,
3421    community: &CommunityV2,
3422    view: &AuthorityView,
3423    create_if_missing: bool,
3424) -> Result<Option<String>, String> {
3425    use crate::community::roles::{Permissions, Role, RoleScope};
3426    // The finder tests the FROZEN founding mask, never ADMIN_ALL: published
3427    // Admin roles predate later bits (PIN_MESSAGES...), and requiring a bit
3428    // they can't have would orphan every one of them and mint a duplicate.
3429    if let Some(r) = view
3430        .roles
3431        .roles
3432        .iter()
3433        .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
3434    {
3435        return Ok(Some(r.role_id.clone()));
3436    }
3437    if !create_if_missing {
3438        return Ok(None);
3439    }
3440    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3441    let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3442    set_role(transport, community, &Role::admin(role_id.clone())).await?;
3443    Ok(Some(role_id))
3444}
3445
3446/// Grant the @admin role (minting it deterministically when absent), MERGED into
3447/// the member's existing grant — a grant entity replaces whole (CORD-04 §2), so a
3448/// blind push would erase their other roles. Owner-only: the position-1 Admin is
3449/// manageable only by position 0 (an equal never outranks it), and refusing
3450/// before any publish keeps an unauthorized edition of the DETERMINISTIC admin
3451/// entity from advancing this device's own floor onto a head readers reject.
3452pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3453    // Guard spans the multi-page fetch below: a swap mid-fetch must not let the
3454    // downstream publish's own (post-swap) guard write account A's floor into B.
3455    let session = SessionGuard::capture();
3456    let my_pk = me_pk()?;
3457    if my_pk != community.owner()? {
3458        return Err("only the community owner can grant @admin".to_string());
3459    }
3460    let view = fetch_authority(transport, community).await;
3461    if !session.is_valid() {
3462        return Err("account changed during grant".to_string());
3463    }
3464    let member_hex = member.to_hex();
3465    require_grant_head(community, &view, &member_hex)?;
3466    let role_id = ensure_admin_role(transport, community, &view, true)
3467        .await?
3468        .expect("create_if_missing yields an id");
3469    let mut role_ids = view
3470        .roles
3471        .grants
3472        .iter()
3473        .find(|g| g.member == member_hex)
3474        .map(|g| g.role_ids.clone())
3475        .unwrap_or_default();
3476    if role_ids.contains(&role_id) {
3477        return Ok(()); // already admin — don't bump the grant edition for nothing.
3478    }
3479    role_ids.push(role_id);
3480    grant_roles(transport, community, member, role_ids).await
3481}
3482
3483/// Strip the @admin role from the member's grant, preserving their other roles.
3484/// A no-op when they don't hold it. Owner-only, like [`grant_admin`].
3485pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3486    let session = SessionGuard::capture();
3487    let my_pk = me_pk()?;
3488    if my_pk != community.owner()? {
3489        return Err("only the community owner can revoke @admin".to_string());
3490    }
3491    let view = fetch_authority(transport, community).await;
3492    if !session.is_valid() {
3493        return Err("account changed during revoke".to_string());
3494    }
3495    let member_hex = member.to_hex();
3496    require_grant_head(community, &view, &member_hex)?;
3497    let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3498        return Ok(()); // no admin role exists — nothing to revoke.
3499    };
3500    let mut role_ids = view
3501        .roles
3502        .grants
3503        .iter()
3504        .find(|g| g.member == member_hex)
3505        .map(|g| g.role_ids.clone())
3506        .unwrap_or_default();
3507    let before = role_ids.len();
3508    role_ids.retain(|r| r != &role_id);
3509    if role_ids.len() == before {
3510        return Ok(());
3511    }
3512    grant_roles(transport, community, member, role_ids).await
3513}
3514
3515/// A grant replaces whole — refuse the merge when this member's grant is FLOORED
3516/// locally but no head folded (withheld / evicted): a blind push at that point
3517/// would erase their other roles at a higher version.
3518fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3519    let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3520        return Err("malformed member key".to_string());
3521    };
3522    let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3523    if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3524        return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3525    }
3526    Ok(())
3527}
3528
3529/// Replace the Banlist (vsk 4, CORD-04 §4) with `banned` (lowercase-hex npubs), the
3530/// whole list on every edit. Gated on the reader side by `BAN` per head plus strict
3531/// outrank per entry — mirrored here BEFORE the publish, so an unauthorized caller
3532/// (an SDK bot without the bit, a demoted moderator) fails loudly instead of
3533/// publishing an edition every reader silently rejects while its own local echo
3534/// caches the phantom ban.
3535pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3536    let session = SessionGuard::capture();
3537    super::roles::validate_banlist(banned)?;
3538    {
3539        let my_pk = me_pk()?;
3540        let owner = community.owner()?;
3541        if my_pk != owner {
3542            let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3543            let me_hex = my_pk.to_hex();
3544            let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3545            if current.contains(&me_hex) {
3546                return Err("you are banned from this community".to_string());
3547            }
3548            let roster = crate::db::community::get_community_roles(&cid_hex)?;
3549            let owner_hex = owner.to_hex();
3550            if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
3551                return Err("editing the banlist needs the BAN permission".to_string());
3552            }
3553            // Only the entries this edit ADDS need the per-target outrank (the fold
3554            // keeps prior authorized bans alive through its per-candidate history).
3555            for target in banned.iter().filter(|t| !current.contains(*t)) {
3556                if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
3557                    return Err("you do not outrank a member this ban targets".to_string());
3558                }
3559            }
3560        }
3561    }
3562    let content = super::roles::banlist_content_json(banned)?;
3563    let eid = super::derive::banlist_locator(community.id());
3564    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3565    let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
3566    // The version this publish will chain to — mirrors publish_control_edition.
3567    let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3568        Some((v, _)) => v + 1,
3569        None => 1,
3570    };
3571    publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await?;
3572    // ECHO the published list into the local cache at once. Without this, the
3573    // cache only moves on a successful control-plane fold — and a caller
3574    // composing ban steps (banlist → grant strip → refound) re-reads the STALE
3575    // list if any later step trips before the fold, so each new banlist
3576    // edition it builds ERASES every ban since the last fold. Nineteen bans in
3577    // production each overwrote their predecessor exactly this way.
3578    if session.is_valid() {
3579        let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
3580    }
3581    Ok(())
3582}
3583
3584/// Edit the community metadata (vsk 0, CORD-02 §6). Gated on the reader side by
3585/// `MANAGE_METADATA`.
3586pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3587    let session = SessionGuard::capture();
3588    ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
3589    control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3590    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3591    publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3592}
3593
3594/// Persist a freshly-published icon/banner onto the held row and return the fresh
3595/// row. Reloads under the community's follow lock: `save_community_v2` is a
3596/// whole-row save that prunes channels absent from the passed struct, so writing
3597/// a stale pre-upload copy would drop rows a concurrent fold just landed.
3598pub async fn persist_community_image(
3599    id: &crate::community::CommunityId,
3600    img: control::ImageRef,
3601    is_banner: bool,
3602    session: &SessionGuard,
3603) -> Option<CommunityV2> {
3604    let lock = super::realtime::follow_lock(id);
3605    let _guard = lock.lock().await;
3606    if !session.is_valid() {
3607        return None;
3608    }
3609    let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3610    if is_banner {
3611        fresh.banner = Some(img);
3612    } else {
3613        fresh.icon = Some(img);
3614    }
3615    crate::db::community::save_community_v2(&fresh).ok()?;
3616    Some(fresh)
3617}
3618
3619/// Add or edit a channel's metadata (vsk 2, CORD-03 §2). `channel_id` is the
3620/// coordinate. Gated on the reader side by `MANAGE_CHANNELS`.
3621pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3622    let session = SessionGuard::capture();
3623    let my_pk = me_pk()?;
3624    ensure_channel_manager(community, &my_pk)?;
3625    let old_name = community.channel(channel_id).map(|c| c.name.clone());
3626    // Public → private CONVERSION is a key rotation (CORD-03 §2) this build doesn't
3627    // mint yet — refuse the flag flip rather than publish an edition no reader can
3628    // key (members would keep posting on the root-derived plane, splitting the
3629    // channel). Private → public works (readers heal to the root derivation).
3630    if meta.private {
3631        if let Some(held) = community.channel(channel_id) {
3632            if !held.private {
3633                return Err("converting a public channel to private is not supported yet".to_string());
3634            }
3635        }
3636    }
3637    control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3638    let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3639    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3640    // Apply locally too. The fold is the authority but runs later, so without this
3641    // an edit we just made reads back stale until some future control pass — the
3642    // rename appears to have silently failed.
3643    if !session.is_valid() {
3644        return Ok(());
3645    }
3646    if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3647        if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3648            ch.name = meta.name.clone();
3649            ch.private = meta.private;
3650            ch.voice = meta.voice;
3651            ch.meta_custom = meta.custom.clone();
3652            ch.meta_extra = meta.extra.clone();
3653            crate::db::community::save_community_v2(&held)?;
3654        }
3655    }
3656    // Keep the companion access role's label in step with the channel it gates.
3657    if meta.private {
3658        if let Some(old) = old_name.filter(|o| *o != meta.name) {
3659            rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3660        }
3661    }
3662    Ok(())
3663}
3664
3665/// Rename a private channel's companion access role to follow the channel (CORD-04 §2).
3666/// Best-effort and never fatal: the channel rename has already published, and a role's
3667/// name is cosmetic — entitlement is carried by the scope, not the label.
3668///
3669/// Only renames a label still equal to the channel's OLD name, so a deliberately
3670/// customised role name survives a channel rename untouched.
3671async fn rename_channel_access_role<T: Transport + ?Sized>(
3672    transport: &T,
3673    community: &CommunityV2,
3674    channel_id: &ChannelId,
3675    old_name: &str,
3676    new_name: &str,
3677    session: &SessionGuard,
3678) {
3679    let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3680        return;
3681    };
3682    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3683    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3684    // Fetched, not cached: `set_role` republishes the WHOLE role body, so a stale
3685    // cache would clobber a permission edit this client has not folded yet.
3686    let mut roster = fetch_authority(transport, community).await.roles;
3687    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3688    for r in cached.roles {
3689        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3690            roster.roles.push(r);
3691        }
3692    }
3693    if !session.is_valid() {
3694        return;
3695    }
3696    // MANAGE_CHANNELS got us the rename; the role edition needs MANAGE_ROLES + outrank
3697    // of its own. Publishing one readers reject would wedge our later, legitimate role
3698    // edits behind a rejected chain, so verify before publishing rather than after.
3699    let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3700    if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3701        return;
3702    }
3703    // Same selector `grant_channel_access` vends: the permission-less scoped role. A
3704    // per-channel moderator role sharing the scope is NOT the access list.
3705    let Some(mut role) = roster
3706        .channel_roles(&chan_hex)
3707        .into_iter()
3708        .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3709        .cloned()
3710    else {
3711        return;
3712    };
3713    if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3714        return;
3715    }
3716    role.name = new_name.to_string();
3717    if let Err(e) = set_role(transport, community, &role).await {
3718        crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3719        return;
3720    }
3721    if session.is_valid() {
3722        merge_local_roster(&cid_hex, Some(&role), None);
3723    }
3724}
3725
3726/// The local mirror of a reader-side permission fold gate: the owner, or a
3727/// roster-authorized holder of `needed` who isn't banned. Refusing BEFORE any
3728/// publish keeps an unauthorized device from advancing its own edition floor onto
3729/// a head every reader rejects (wedging its later, legitimately-authorized edits
3730/// behind a rejected chain). Fail-closed: an empty/unfolded roster collapses to
3731/// owner-only — it can over-restrict, never grant authority no one has.
3732fn ensure_folded_permission(
3733    community: &CommunityV2,
3734    me: &PublicKey,
3735    needed: u64,
3736    verb: &str,
3737) -> Result<(), String> {
3738    let owner = community.owner()?;
3739    if *me == owner {
3740        return Ok(());
3741    }
3742    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3743    let me_hex = me.to_hex();
3744    if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3745        return Err("you are banned from this community".to_string());
3746    }
3747    let roster = crate::db::community::get_community_roles(&cid_hex)?;
3748    if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
3749        Ok(())
3750    } else {
3751        Err(format!("{verb} needs the {} permission", permission_label(needed)))
3752    }
3753}
3754
3755fn permission_label(bits: u64) -> &'static str {
3756    use crate::community::roles::Permissions;
3757    match bits {
3758        Permissions::MANAGE_ROLES => "MANAGE_ROLES",
3759        Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
3760        Permissions::MANAGE_METADATA => "MANAGE_METADATA",
3761        Permissions::BAN => "BAN",
3762        Permissions::CREATE_INVITE => "CREATE_INVITE",
3763        _ => "required",
3764    }
3765}
3766
3767/// [`ensure_folded_permission`] for `MANAGE_CHANNELS` (CORD-03 §2).
3768fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3769    ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
3770}
3771
3772/// Create a new PUBLIC channel (CORD-03 §2): mint a fresh id, publish its metadata
3773/// edition (vsk 2), and add it to the held community. A Public channel derives its Chat
3774/// Plane from the `community_root` (no per-channel key), so other members fold it in on
3775/// their next control follow with nothing to distribute. Returns the new channel id.
3776/// Reader-gated by `MANAGE_CHANNELS`.
3777pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3778    let channel_id = ChannelId(super::super::random_32());
3779    create_public_channel_with_id(transport, community, name, channel_id).await?;
3780    Ok(channel_id)
3781}
3782
3783/// [`create_public_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3784/// (§migration) that reuses a v1 channel's id so chat history stitches through the flip.
3785/// Asserts the id isn't already live in a DIFFERENT held v2 community before minting.
3786pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3787    let session = SessionGuard::capture();
3788    // Serialize with the follow worker: the save below writes the WHOLE community
3789    // row from this caller's struct, so an unserialized concurrent follow adopting
3790    // a rotation would be rolled back to a stale root (a deaf community).
3791    let lock = super::realtime::follow_lock(community.id());
3792    let _guard = lock.lock().await;
3793    let my_pk = me_pk()?;
3794    ensure_channel_manager(community, &my_pk)?;
3795    assert_channel_id_free(&channel_id, community.id())?;
3796    let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3797    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3798    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3799    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3800    if !session.is_valid() {
3801        return Err("account changed during channel create".to_string());
3802    }
3803    // Add locally + persist so the creator can post immediately (peers fold it in).
3804    let mut updated = community.clone();
3805    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() });
3806    crate::db::community::save_community_v2(&updated)?;
3807    Ok(())
3808}
3809
3810/// Refuse a channel id already live in a DIFFERENT held v2 community — the same
3811/// cross-community hijack the `save_community_v2` guard forecloses, checked up front so a
3812/// migration twin never adopts an id it doesn't own. A collision with a v1-owned row is
3813/// fine (that's the whole point — the flip re-parents it); only a foreign v2 owner blocks.
3814fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3815    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3816    if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3817        let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3818        let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3819        if existing != mine
3820            && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3821        {
3822            return Err("channel id is already live in another v2 community".to_string());
3823        }
3824    }
3825    Ok(())
3826}
3827
3828/// Create a new PRIVATE channel (CORD-03 §2): mint a fresh id + an independent
3829/// random key at channel-epoch 1, mint a companion channel-scoped Role that is
3830/// the channel's access list (CORD-04 §2), deliver the key to the entitled over
3831/// the rekey plane (CORD-06 §1), then announce the channel (vsk 2, `private`).
3832/// Epoch 0 is the root generation ("the first privatisation is epoch 1"), so the
3833/// delivery commits its continuity to `(0, community_root)` — verifiable by every
3834/// member and bound to THIS community's root. The key ships BEFORE the
3835/// announcement: an aborted attempt leaves only an unannounced crate (invisible),
3836/// and a retry mints a fresh id, so there is no same-coordinate double-mint to
3837/// fork on. Live public links are refreshed; they carry no private key, so this
3838/// only re-states the public set.
3839pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3840    let channel_id = ChannelId(super::super::random_32());
3841    create_private_channel_with_id(transport, community, name, channel_id).await?;
3842    Ok(channel_id)
3843}
3844
3845/// The companion Role minted alongside a Private channel — the channel's access
3846/// list (CORD-04 §2 `scope: {"kind":"channel"}`). Same name as the channel, and
3847/// **no permission bits**: it confers read access, which is key possession, never
3848/// authority. Position sits below every management role for the same reason.
3849pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3850    use crate::community::roles::{Permissions, Role, RoleScope};
3851    Role {
3852        role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3853        name: name.to_string(),
3854        position: u32::MAX - 1,
3855        permissions: Permissions::empty(),
3856        scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3857        color: 0,
3858    }
3859}
3860
3861/// [`create_private_channel`] with a CALLER-CHOSEN id — the migration-only entry point
3862/// (§migration) reusing a v1 private channel's id so history stitches through the flip.
3863pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3864    let session = SessionGuard::capture();
3865    // Serialize with the follow worker across the whole fetch→publish→save span
3866    // (the memberlist fetch is seconds long; an unserialized follow adopting a
3867    // rotation meanwhile would be rolled back by the whole-row save below).
3868    let lock = super::realtime::follow_lock(community.id());
3869    let _guard = lock.lock().await;
3870    let signer = crate::signer::active_signer()?;
3871    let my_pk = me_pk()?;
3872    ensure_channel_manager(community, &my_pk)?;
3873    assert_channel_id_free(&channel_id, community.id())?;
3874    let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3875    control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3876    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3877
3878    let channel_key = super::super::random_32();
3879    let epoch = Epoch(1);
3880
3881    // The channel's access list: a companion channel-scoped Role (CORD-04 §2),
3882    // granted to me so the creator is entitled from the first edition.
3883    let access_role = channel_access_role(&channel_id, name);
3884    let access_role_ids = vec![access_role.role_id.clone()];
3885
3886    // Recipients are the ENTITLED, not the memberlist: CORD-03's private channel
3887    // is "readable only by granted role-holders". At create that is me (plus the
3888    // owner, who is always entitled) — everyone else keys up when granted.
3889    let owner = community.owner()?;
3890    let mut recipients = vec![my_pk];
3891    if owner != my_pk {
3892        recipients.push(owner);
3893    }
3894    let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3895    let mut blobs = Vec::with_capacity(recipients.len());
3896    for r in &recipients {
3897        blobs.push(
3898            rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3899                .await
3900                .map_err(|e| e.to_string())?,
3901        );
3902    }
3903    let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3904    let at_secs = now_ms() / 1000;
3905    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())
3906        .await
3907        .map_err(|e| e.to_string())?;
3908    if !session.is_valid() {
3909        return Err("account changed during channel create".to_string());
3910    }
3911    for c in &chunks {
3912        transport.publish_durable(c, &community.relays).await?;
3913    }
3914    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3915    if !session.is_valid() {
3916        return Err("account changed during channel create".to_string());
3917    }
3918    // Publish the access list AFTER the channel exists, so a peer folding the
3919    // Role always resolves the channel it scopes to. A failure here leaves a
3920    // channel only its creator can read — recoverable by re-granting, never a
3921    // leak.
3922    set_role(transport, community, &access_role).await?;
3923    grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3924    if !session.is_valid() {
3925        return Err("account changed during channel create".to_string());
3926    }
3927    // The fold is the authority but runs later; without this the creator is not
3928    // yet entitled to their own channel and the next grant finds no access role.
3929    merge_local_roster(
3930        &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3931        Some(&access_role),
3932        Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3933    );
3934    // A leave/delete raced the create: saving would resurrect the community row.
3935    if crate::db::community::community_protocol(community.id())?.is_none() {
3936        return Err("community removed during channel create".to_string());
3937    }
3938    let mut updated = community.clone();
3939    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() });
3940    crate::db::community::save_community_v2(&updated)?;
3941    // Archive the epoch-1 key so this channel's history stays readable across its
3942    // future rotations (CORD-03 §3).
3943    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3944    crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3945    // Re-state live links. They carry no private key (CORD-05 §2 — a link's
3946    // audience holds no Role), so this only refreshes the public set.
3947    let _ = refresh_public_links(transport, &updated).await;
3948    Ok(())
3949}
3950
3951/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
3952// ── Receiving a key vend (CORD-03 "delivered on grant") ──────────────────────
3953
3954/// What a client should do with a vended Private-Channel key right now.
3955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3956pub enum VendVerdict {
3957    /// Every rule passed — adopt the key.
3958    Accept,
3959    /// Cannot judge YET: our fold lags the grant it delivers. Park quietly and
3960    /// re-judge after the next control follow. NOT an anomaly — a lagging fold
3961    /// is the normal case for a vend that races its own Grant.
3962    Park(&'static str),
3963    /// Judged invalid against evidence that cannot become true later. Alarm-worthy.
3964    Refuse(&'static str),
3965}
3966
3967/// Judge a vended Private-Channel key against our OWN folded state.
3968///
3969/// The Grant is the authority half and rides the owner-rooted control plane, so
3970/// it cannot be forged; the vend is only delivery. Acceptance therefore rests
3971/// entirely on what our own fold proves — a bundle can never introduce a channel
3972/// our control plane doesn't define, which is what closes the hidden-channel
3973/// injection class.
3974///
3975/// `community` must already be the held (self-certified) community: the caller
3976/// resolves it by `community_id`, so a bundle naming a community we're not in is
3977/// never judged here at all.
3978pub fn judge_channel_key_vend(
3979    community: &CommunityV2,
3980    roster: &crate::community::roles::CommunityRoles,
3981    channel_id: &ChannelId,
3982    epoch: Epoch,
3983    sender_hex: &str,
3984) -> VendVerdict {
3985    let me = match me_pk() {
3986        Ok(pk) => pk.to_hex(),
3987        Err(_) => return VendVerdict::Park("no active identity"),
3988    };
3989    let owner_hex = match community.owner() {
3990        Ok(o) => o.to_hex(),
3991        Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3992    };
3993    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3994
3995    // (2) The channel must exist in OUR fold, and be private there. The bundle's
3996    // own claims are ignored: a vend may deliver a key, never define a channel.
3997    let Some(ch) = community.channel(channel_id) else {
3998        return VendVerdict::Park("channel not in our fold yet");
3999    };
4000    if !ch.private {
4001        // Never heals: our owner-rooted fold says this channel is public, so a
4002        // "private key" for it is a spoof, not a lagging view.
4003        return VendVerdict::Refuse("vend names a channel our fold says is public");
4004    }
4005
4006    // (5) Epoch sanity, BOTH directions. Below is superseded by the rotation that
4007    // produced our copy. Above matters more: the channel head is monotonic, so a
4008    // wildly-ahead epoch is not merely wrong, it is PERMANENT — every genuine
4009    // rotation afterwards lands at `head + 1`, is refused as stale, and the
4010    // channel dies for us with no heal path at all (not a rekey, not a re-grant,
4011    // not a refound). Rotations advance one epoch at a time, so a lead this large
4012    // is never a delivery we could place.
4013    if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
4014        return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
4015    }
4016    if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
4017        return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
4018    }
4019
4020    // (3) OUR fold must show US granted a role scoped to this channel. This is
4021    // the rule that kills the spoof class: an attacker cannot forge the Grant,
4022    // so they cannot make us accept a key for a channel we were never granted.
4023    if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
4024        return VendVerdict::Park("our grant for this channel has not folded yet");
4025    }
4026
4027    // (4) The vendor must be entitled too — they hold the real key, so a wrong
4028    // key from them costs isolation, never confidentiality.
4029    if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
4030        return VendVerdict::Park("vendor's entitlement has not folded yet");
4031    }
4032
4033    VendVerdict::Accept
4034}
4035
4036/// How long an unprovable parked vend is kept. Deliberately long: the fallback
4037/// heal is the channel's next rotation, which may never come.
4038const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
4039
4040/// How far above our channel head a vend may claim to be. Generous — a keyless
4041/// cursor can lag a busy channel by many rotations — but bounded, because the
4042/// head is monotonic and an over-advance can never be walked back.
4043const MAX_VEND_EPOCH_LEAD: u64 = 1024;
4044
4045/// Re-judge every parked key vend for this community and adopt the ones that now
4046/// pass. Runs after a control follow (the fold moved, so verdicts can change) and
4047/// on the boot sweep.
4048///
4049/// Returns the channels newly keyed up.
4050pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
4051    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4052    let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
4053        Ok(p) if !p.is_empty() => p,
4054        _ => return Vec::new(),
4055    };
4056    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4057    let mut adopted = Vec::new();
4058    let now = now_ms() / 1000;
4059    for p in parked {
4060        // Several candidates may name one channel (parking is open to any sender,
4061        // so a stranger can never suppress the entitled vendor's key by holding a
4062        // slot). Once one is seated the rest are moot.
4063        if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
4064            let _ = crate::db::community::drop_pending_channel_key(p.id);
4065            continue;
4066        }
4067        // A vend we were never able to prove is not kept forever: an admin who
4068        // adds then immediately removes someone leaves a row nothing will ever
4069        // discharge. Generous by design — the alternative heal (the channel's
4070        // next rotation) can be arbitrarily far away, so this is hygiene, not a
4071        // deadline.
4072        if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
4073            let _ = crate::db::community::drop_pending_channel_key(p.id);
4074            continue;
4075        }
4076        let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
4077            let _ = crate::db::community::drop_pending_channel_key(p.id);
4078            continue;
4079        };
4080        let channel_id = ChannelId(id_bytes);
4081        match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
4082            VendVerdict::Accept => {
4083                if !session.is_valid() {
4084                    return adopted;
4085                }
4086                // First delivery vs rotation. A keyless channel must bypass the
4087                // monotonic guard: it sits at the epoch-0 cursor, and a peer that
4088                // mints born-private channels at epoch 0 vends that same epoch, so
4089                // `new > current` would refuse the only key on offer.
4090                let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
4091                let seated = if keyless {
4092                    crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
4093                } else {
4094                    crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4095                };
4096                if let Err(e) = seated {
4097                    crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4098                    continue;
4099                }
4100                // The key landed — every other candidate for this channel is moot.
4101                let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4102                adopted.push(channel_id);
4103            }
4104            VendVerdict::Refuse(why) => {
4105                crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4106                // Only THIS candidate — a sibling may still be the genuine vend.
4107                let _ = crate::db::community::drop_pending_channel_key(p.id);
4108            }
4109            // Quiet by design: the fold simply hasn't caught up.
4110            VendVerdict::Park(_) => {}
4111        }
4112    }
4113    adopted
4114}
4115
4116/// Grant `member` read access to a Private channel (CORD-03 "delivered on
4117/// grant"): publish a Grant adding the channel's access role, then vend the key
4118/// as a CORD-05 §6 Direct Invite whose bundle carries exactly the channels they
4119/// are now entitled to.
4120///
4121/// The Grant is the authority half and rides the owner-rooted control plane, so
4122/// it cannot be forged; the vend is only delivery. A recipient accepts the key
4123/// solely on the strength of their OWN fold showing this grant — the bundle can
4124/// never introduce a channel their control plane doesn't define.
4125pub async fn grant_channel_access<T: Transport + ?Sized>(
4126    transport: &T,
4127    community: &CommunityV2,
4128    channel_id: &ChannelId,
4129    member: &PublicKey,
4130) -> Result<(), String> {
4131    let session = SessionGuard::capture();
4132    let my_pk = me_pk()?;
4133    let ch = community.channel(channel_id).ok_or("unknown channel")?;
4134    if !ch.private {
4135        return Err("channel is public — every member already reads it".to_string());
4136    }
4137    if ch.key.is_none() {
4138        return Err("we hold no key for this channel, so we cannot vend it".to_string());
4139    }
4140    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4141    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4142    let owner_hex = community.owner()?.to_hex();
4143    // A Grant REPLACES the member's role set, so the union it is built from must
4144    // be CURRENT: a stale local roster would silently strip every role this
4145    // client hasn't folded yet. Fetch the authority fresh rather than trusting
4146    // the cache, and merge the local view on top so a role we just published
4147    // ourselves (which the plane has but no fold has read back) survives too.
4148    let mut roster = fetch_authority(transport, community).await.roles;
4149    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4150    for r in cached.roles {
4151        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4152            roster.roles.push(r);
4153        }
4154    }
4155    for g in cached.grants {
4156        if !roster.grants.iter().any(|x| x.member == g.member) {
4157            roster.grants.push(g);
4158        }
4159    }
4160    if !session.is_valid() {
4161        return Err("account changed during grant".to_string());
4162    }
4163    // Reader-gated by MANAGE_ROLES, like any Grant; narrowed to this channel so
4164    // a channel-scoped manager can run its own access list.
4165    if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4166        return Err("not authorized to manage this channel's access".to_string());
4167    }
4168    // The channel's roles are ordered by AUTHORITY, so `.first()` is the most
4169    // privileged — granting read access must never hand out a per-channel
4170    // moderator role that happens to share the scope. Pick the permission-less
4171    // one: conferring read access is exactly what carries no authority.
4172    let role_id = roster
4173        .channel_roles(&chan_hex)
4174        .into_iter()
4175        .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4176        .map(|r| r.role_id.clone())
4177        .ok_or("channel has no permission-less access role to grant")?;
4178
4179    let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4180    if !role_ids.contains(&role_id) {
4181        role_ids.push(role_id.clone());
4182    }
4183    grant_roles(transport, community, member, role_ids.clone()).await?;
4184    if !session.is_valid() {
4185        return Err("account changed during grant".to_string());
4186    }
4187    merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4188    // Settle the vend against the Grant we JUST published — the fold lags it.
4189    let bundle = bundle_of_with_overlay(
4190        community,
4191        BundleAudience::Member(*member),
4192        Some(my_pk),
4193        None,
4194        None,
4195        std::slice::from_ref(&role_id),
4196        &[],
4197    );
4198    let signer = crate::signer::active_signer()?;
4199    let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4200    if !session.is_valid() {
4201        return Err("account changed before vending the key".to_string());
4202    }
4203    transport.publish(&wrap, &community.relays).await?;
4204    Ok(())
4205}
4206
4207/// Revoke `member`'s read access to a Private channel (CORD-03 "rekeyed on
4208/// removal"): drop the channel's access role from their Grant, then rotate the
4209/// channel to its next epoch delivering the fresh key to everyone still
4210/// entitled (CORD-06). The revoked member keeps whatever history they already
4211/// read — a rekey protects the future, never the past.
4212pub async fn revoke_channel_access<T: Transport + ?Sized>(
4213    transport: &T,
4214    community: &CommunityV2,
4215    channel_id: &ChannelId,
4216    member: &PublicKey,
4217) -> Result<(), String> {
4218    let session = SessionGuard::capture();
4219    let my_pk = me_pk()?;
4220    let ch = community.channel(channel_id).ok_or("unknown channel")?;
4221    if !ch.private {
4222        return Err("channel is public — there is no access to revoke".to_string());
4223    }
4224    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4225    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4226    let owner_hex = community.owner()?.to_hex();
4227    // Same replace-not-merge hazard as the grant: the retained set must be built
4228    // from a CURRENT roster or this revoke strips roles we simply hadn't folded.
4229    let mut roster = fetch_authority(transport, community).await.roles;
4230    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4231    for r in cached.roles {
4232        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4233            roster.roles.push(r);
4234        }
4235    }
4236    for g in cached.grants {
4237        if !roster.grants.iter().any(|x| x.member == g.member) {
4238            roster.grants.push(g);
4239        }
4240    }
4241    if !session.is_valid() {
4242        return Err("account changed during revoke".to_string());
4243    }
4244    if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4245        return Err("not authorized to manage this channel's access".to_string());
4246    }
4247    if *member == community.owner()? {
4248        return Err("the owner is supreme and cannot be removed".to_string());
4249    }
4250    let access_ids = roster.channel_role_ids(&chan_hex);
4251    // Without the access list this revoke is a no-op that still ROTATES, and the
4252    // rotation's recipient filter would match nobody — cutting off every
4253    // legitimately entitled member. Refuse rather than mass-evict.
4254    if access_ids.is_empty() {
4255        return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4256    }
4257    let remaining: Vec<String> = roster
4258        .roles_of(&member.to_hex())
4259        .map(|r| r.role_id.clone())
4260        .filter(|id| !access_ids.contains(id))
4261        .collect();
4262    grant_roles(transport, community, member, remaining.clone()).await?;
4263    if !session.is_valid() {
4264        return Err("account changed during revoke".to_string());
4265    }
4266    merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4267    // Rotate so the removal actually severs them (CORD-06 §1). The revoked
4268    // member is excluded from the recipient set by the overlay, since the fold
4269    // has not yet caught the Grant we just published.
4270    rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4271}
4272
4273/// Rotate one Private channel to its next epoch, delivering the fresh key to
4274/// everyone entitled EXCEPT `removed` (CORD-06 §1 single-channel rekey).
4275///
4276/// `roster` must be the caller's CURRENT view (fetched, not the local cache):
4277/// the recipient set is built from it, so a cached roster silently drops every
4278/// member granted since this client last folded — they keep a dead key with no
4279/// heal path. `access_ids` is that roster's access-role set for this channel;
4280/// `removed` is excluded explicitly, since the revoking Grant was published
4281/// moments ago and no fold has caught it.
4282async fn rekey_channel_excluding<T: Transport + ?Sized>(
4283    transport: &T,
4284    community: &CommunityV2,
4285    channel_id: &ChannelId,
4286    roster: &crate::community::roles::CommunityRoles,
4287    access_ids: &[String],
4288    removed: &PublicKey,
4289) -> Result<(), String> {
4290    let session = SessionGuard::capture();
4291    // Whole-row save below — serialize with the follow worker (see create_*_channel).
4292    let lock = super::realtime::follow_lock(community.id());
4293    let _guard = lock.lock().await;
4294    let signer = crate::signer::active_signer()?;
4295    let my_pk = me_pk()?;
4296    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4297    let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4298    let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4299    let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4300    let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4301    let owner = community.owner()?;
4302    let owner_hex = owner.to_hex();
4303
4304    // Everyone still entitled: the owner (always), me (the rotator must be able
4305    // to read what it rekeys), and every member the roster shows holding an
4306    // access role — minus the removal.
4307    let removed_hex = removed.to_hex();
4308    let mut recipients: Vec<PublicKey> = vec![my_pk];
4309    if owner != my_pk {
4310        recipients.push(owner);
4311    }
4312    for g in &roster.grants {
4313        if g.member == removed_hex || g.member == owner_hex {
4314            continue;
4315        }
4316        if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4317            continue;
4318        }
4319        if let Ok(pk) = PublicKey::parse(&g.member) {
4320            if !recipients.contains(&pk) {
4321                recipients.push(pk);
4322            }
4323        }
4324    }
4325    // Mint-or-reuse keyed by (channel, next epoch) so a retry after a partial
4326    // publish re-uses the same key instead of forking the epoch.
4327    let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4328    let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4329    let mut blobs = Vec::with_capacity(recipients.len());
4330    for r in &recipients {
4331        blobs.push(
4332            rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4333                .await
4334                .map_err(|e| e.to_string())?,
4335        );
4336    }
4337    let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4338    let at_secs = now_ms() / 1000;
4339    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())
4340        .await
4341        .map_err(|e| e.to_string())?;
4342    if !session.is_valid() {
4343        return Err("account changed during channel rekey".to_string());
4344    }
4345    for c in &chunks {
4346        transport.publish_durable(c, &community.relays).await?;
4347    }
4348    if !session.is_valid() {
4349        return Err("account changed during channel rekey".to_string());
4350    }
4351    if crate::db::community::community_protocol(community.id())?.is_none() {
4352        return Err("community removed during channel rekey".to_string());
4353    }
4354    // Adopt locally + archive, so our own history reads across the rotation.
4355    crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4356    crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4357
4358    // §7 Rotator duty: reseal the Pin List under the NEW key. Without this,
4359    // members who join at this epoch hold no old key and the channel's pins
4360    // read as sealed-dark for them forever. The rotator is uniquely placed:
4361    // it provably reads the old seal (it held the old key) and mints the new
4362    // one. Best-effort — a failed reseal never fails the rotation, and any
4363    // curator's next edit heals the same way.
4364    {
4365        let mut rotated = community.clone();
4366        if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
4367            c.key = Some(new_key);
4368            c.epoch = new_epoch;
4369        }
4370        match read_channel_pins(&rotated, channel_id) {
4371            Ok(read) if !read.sealed && !read.pins.is_empty() => {
4372                let entries: Vec<super::pins::PinEntry> =
4373                    read.pins.iter().map(|p| p.entry.clone()).collect();
4374                if let Some(ch2) = rotated.channel(channel_id).cloned() {
4375                    if let Err(e) = publish_pin_list(transport, &rotated, &session, &ch2, &entries).await {
4376                        crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
4377                    } else {
4378                        crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
4379                    }
4380                }
4381            }
4382            Ok(read) if read.sealed => {
4383                crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
4384            }
4385            _ => {}
4386        }
4387    }
4388    Ok(())
4389}
4390
4391/// Rotate every private channel a just-banned member could read (CORD-06 §1 applied
4392/// per channel). A Public-community Ban skips the Refounding (CORD-05 §5), but the
4393/// banlist and grant strip alone leave the member holding each private channel's
4394/// CURRENT epoch key — rotation is the only read severance.
4395///
4396/// `stripped_roles` is the member's role set as captured before the strip: the fold
4397/// may or may not have caught the strip yet, and the `with` overlay makes the
4398/// entitlement judgment independent of that timing.
4399///
4400/// Offer-side mirror of the reader's `channel_rotator_ok`: a channel rotation is
4401/// honored only from `MANAGE_CHANNELS` holders, so a BAN-only moderator must not
4402/// publish one — locally adopting an epoch every reader rejects forks the channel.
4403///
4404/// Best-effort per channel (one failure must not leave the others unrotated);
4405/// returns how many channels rotated, or the joined failures.
4406pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
4407    transport: &T,
4408    community: &CommunityV2,
4409    member: &PublicKey,
4410    stripped_roles: &[String],
4411) -> Result<usize, String> {
4412    let session = SessionGuard::capture();
4413    let my_pk = me_pk()?;
4414    ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
4415    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4416    // Recipients need the CURRENT entitlement view — fetched, then merged over the
4417    // cache so entitlements we published ourselves survive a lagging fold (the same
4418    // two-strand merge as `revoke_channel_access`).
4419    let mut roster = fetch_authority(transport, community).await.roles;
4420    let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4421    for r in cached.roles {
4422        if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4423            roster.roles.push(r);
4424        }
4425    }
4426    for g in cached.grants {
4427        if !roster.grants.iter().any(|x| x.member == g.member) {
4428            roster.grants.push(g);
4429        }
4430    }
4431    if !session.is_valid() {
4432        return Err("account changed during ban severance".to_string());
4433    }
4434    let owner_hex = community.owner().ok().map(|o| o.to_hex());
4435    let member_hex = member.to_hex();
4436    let mut rotated = 0usize;
4437    let mut failures: Vec<String> = Vec::new();
4438    for ch in &community.channels {
4439        if !ch.private || ch.key.is_none() {
4440            continue;
4441        }
4442        let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
4443        if !roster.is_entitled(owner_hex.as_deref(), &member_hex, &chan_hex, stripped_roles, &[]) {
4444            continue;
4445        }
4446        let access_ids = roster.channel_role_ids(&chan_hex);
4447        // Entitlement without an access-role set can't happen for a non-owner, and
4448        // an empty set would make the rotation's recipient filter mass-evict.
4449        if access_ids.is_empty() {
4450            continue;
4451        }
4452        // Reload per iteration: each rotation advances the held document, and a
4453        // stale struct would mint a colliding channel epoch on the next pass.
4454        let held = match crate::db::community::load_community_v2(community.id()) {
4455            Ok(Some(c)) => c,
4456            _ => return Err("community gone during ban severance".to_string()),
4457        };
4458        match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, member).await {
4459            Ok(()) => rotated += 1,
4460            Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
4461        }
4462        if !session.is_valid() {
4463            return Err("account changed during ban severance".to_string());
4464        }
4465    }
4466    if failures.is_empty() {
4467        Ok(rotated)
4468    } else {
4469        Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
4470    }
4471}
4472
4473/// Tombstone a channel (CORD-03 §2, `deleted: true`) + drop it locally. Reader-gated by
4474/// `MANAGE_CHANNELS`; the coordinate stays folded as a grave so peers hide it.
4475pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4476    let session = SessionGuard::capture();
4477    // Whole-row save below — serialize with the follow worker (see create_*_channel).
4478    let lock = super::realtime::follow_lock(community.id());
4479    let _guard = lock.lock().await;
4480    let my_pk = me_pk()?;
4481    ensure_channel_manager(community, &my_pk)?;
4482    // The tombstone carries the FULL held document (deleted flag set): a strict
4483    // reader treats an edition as the entity, so even a deletion must not strip
4484    // fields it didn't touch (CORD-02 §6).
4485    let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4486        name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4487    });
4488    meta.deleted = Some(true);
4489    let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4490    publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4491    if !session.is_valid() {
4492        return Err("account changed during channel delete".to_string());
4493    }
4494    let mut updated = community.clone();
4495    updated.channels.retain(|c| c.id.0 != channel_id.0);
4496    crate::db::community::save_community_v2(&updated)?;
4497    Ok(())
4498}
4499
4500// ── Live control-follow (CORD-02 §6 / CORD-03 §2) ────────────────────────────
4501
4502/// Re-fold this community's Control Plane and apply the current metadata +
4503/// **public** channel set to the held community, persisting any change. Called
4504/// when a control-plane wrap arrives in realtime (a rename, a new channel, an
4505/// edited description) so a long-running bot tracks the community mid-session
4506/// instead of freezing at its join-time view.
4507///
4508/// **Authority (CORD-04 §5):** the roster (roles/grants/banlist) folds first into
4509/// the owner-seeded authorized set ([`fold_authority`]), then each metadata/channel
4510/// edition is eligible only if its signer CURRENTLY holds the entity's management
4511/// bit (`MANAGE_METADATA`/`MANAGE_CHANNELS`) — so an authorized admin's edits fold,
4512/// a demoted one's drop. The owner is supreme, proven by the self-certifying
4513/// community_id (no network trust).
4514///
4515/// **Private channels are skipped here:** a Private channel's Chat-Plane key is
4516/// delivered over the rekey plane (or an invite bundle), never derivable from a
4517/// control edition alone. A new Private channel therefore surfaces only once
4518/// [`follow_rekeys`] delivers its key. Public channels derive from the
4519/// community_root, so they fold in directly.
4520///
4521/// Returns the updated community iff something changed (so the caller can skip a
4522/// redundant re-subscribe + refresh notification).
4523pub async fn follow_control<T: Transport + ?Sized>(
4524    transport: &T,
4525    community: &CommunityV2,
4526    session: &SessionGuard,
4527) -> Result<Option<CommunityV2>, String> {
4528    community.owner()?; // fail fast if the community is somehow unproven.
4529    let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4530    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4531
4532    // Per-entity refuse-downgrade floors for the CURRENT epoch only. A head recorded
4533    // under a prior epoch is excluded, so that entity auto-bootstraps after a
4534    // Refounding (Armada accepts a compacted head across a dangling prev — matched).
4535    // A read error FAILS CLOSED: an empty map would silently re-open the rollback
4536    // window the floor exists to shut.
4537    let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4538        .into_iter()
4539        .filter(|(_, f)| f.0 == community.root_epoch.0)
4540        .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4541        .collect();
4542
4543    // Newest window first; page OLDER only while a tracking entity is gapped (its
4544    // floor link evicted from the window — H1/M8 refetch), bounded like the join
4545    // verifier. A withholding relay still converges to fail-closed after the cap.
4546    let mut editions: Vec<ParsedEdition> = Vec::new();
4547    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4548    let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4549    let mut oldest: Option<u64> = None;
4550    let mut until: Option<u64> = None;
4551    let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
4552    let mut authority = AuthoritySet::owner_only();
4553    // Whether this round gave up with editions still unread. The follow is
4554    // procedural by design — process what arrives, converge with everyone else —
4555    // so a short read never blocks reading, writing or epoch adoption. It only
4556    // withholds the ROSTER cache below: caching a partial authority as this
4557    // device's baseline is the one step that outlives the round.
4558    let mut truncated = true;
4559    for _ in 0..FOLLOW_MAX_PAGES {
4560        // Quorum, DECLARED (the until→Full transport floor is gone): these
4561        // control reads tolerate a partial union — their fold semantics are
4562        // fail-safe on gaps (seeded banlists, withheld roster cache).
4563        let query = Query {
4564            kinds: vec![stream::KIND_WRAP],
4565            authors: vec![control.pk_hex()],
4566            until,
4567            limit: Some(FOLLOW_PAGE),
4568            evidence: crate::community::transport::Evidence::Quorum,
4569            ..Default::default()
4570        };
4571        let wraps = transport.fetch(&query, &community.relays).await?;
4572        // The `until` cursor is INCLUSIVE (a `-1` step can skip same-second siblings
4573        // at a page boundary); the wrap-id dedup makes re-served boundary events
4574        // free, and a page with nothing new means the relay is exhausted.
4575        let mut fresh = 0usize;
4576        for w in &wraps {
4577            if !seen_wraps.insert(w.id) {
4578                continue;
4579            }
4580            fresh += 1;
4581            let at = w.created_at.as_secs();
4582            if oldest.is_none_or(|o| at < o) {
4583                oldest = Some(at);
4584            }
4585            // Open + seal-verify every edition; authority is resolved by the roster
4586            // fold (CORD-04 §5), not by a signer filter here — an admin's edits fold.
4587            if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4588                if seen.insert(ed.inner_id) {
4589                    editions.push(ed);
4590                }
4591            }
4592        }
4593        // Roster first (roles/grants/banlist → authorized set), then the authority-
4594        // gated metadata/channel fold over the same edition set.
4595        authority = fold_authority(community, &editions, &floors);
4596        fold = apply_control_fold(community, &editions, &floors, &authority);
4597        if !(fold.gapped || authority.gapped) {
4598            truncated = false; // nothing is gapped: this view is coherent
4599            break;
4600        }
4601        if fresh == 0 {
4602            // A FULL page with nothing new is a same-second wall no `until` steps
4603            // past, so older editions stay unreachable; a short page is the end
4604            // of the plane, and a gap in THAT is the relay withholding, not us
4605            // giving up early.
4606            truncated = wraps.len() >= FOLLOW_PAGE;
4607            break;
4608        }
4609        until = oldest;
4610    }
4611
4612    // The fetches straddled awaits; a swap since the guard was captured must not
4613    // write account A's control state into B.
4614    if !session.is_valid() {
4615        return Err("account changed during control follow".to_string());
4616    }
4617    // A leave/delete raced this follow: writing now would resurrect the community
4618    // row and orphan floor rows past delete_community's wipe.
4619    if crate::db::community::community_protocol(community.id())?.is_none() {
4620        return Ok(None);
4621    }
4622    // Persist advanced floors BEFORE the state save (a failed floor write must not
4623    // let saved state outrun its floor), stamping the epoch this fold ran under —
4624    // not the row's write-time value, which a concurrent re-founding can bump. Both
4625    // the metadata/channel heads and the roster/banlist heads advance their floors;
4626    // run the advance (v+1) and same-version convergence (fork tiebreak) paths.
4627    for h in fold.heads.iter().chain(authority.heads.iter()) {
4628        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)?;
4629        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)?;
4630    }
4631    // Persist the authorized banlist content (retained/withholding folds carry None,
4632    // so the stored banlist is left intact — an anti-roster never silently un-bans).
4633    let mut authority_changed = false;
4634    // Ban marks MERGE (never replace): they must outlive both the ban and this window,
4635    // so a later un-ban can't resurrect a pre-ban Join. Persisted even when the banlist
4636    // itself was retained — the history is what the suppression reads.
4637    let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4638    if let Some((banned, version)) = &authority.banlist_persist {
4639        let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4640        crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4641        let mut after = banned.clone();
4642        before.sort();
4643        after.sort();
4644        authority_changed |= before != after;
4645    }
4646    // Persist the authorized roster so capabilities/roles stay sync LOCAL reads
4647    // (v1 parity: the passive follow folds, reads never fetch). Guarded like v1's
4648    // fetch path: only an aggregate built from roster editions at least as new as
4649    // the stored one may replace it — a withholding relay serving NO roster
4650    // editions folds an empty-but-ungapped aggregate (absence raises no gap flag),
4651    // and that must RETAIN the stored roster, never wipe standing.
4652    let newest_roster_at: i64 = editions
4653        .iter()
4654        .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4655        .map(|e| e.created_at as i64)
4656        .max()
4657        .unwrap_or(0);
4658    // Completeness gate: the `gapped` flag only covers entities present in the window.
4659    // A role/grant floored on this device but with ZERO editions fetched (aged out of
4660    // the paging reach) folds absent yet raises no gap — persisting would silently drop
4661    // it. So if any CURRENTLY-STORED entity is floored but folded no head this round,
4662    // RETAIN. A real revoke still folds a head (see select_authorized), so it persists.
4663    let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4664    let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4665    let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4666        && stored.grants.iter().all(|g| {
4667            crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4668                let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4669                !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4670            })
4671        });
4672    // `truncated` covers the case the other three can't: a COLD device (no floors,
4673    // no stored roster) folding under a plane a member has inflated past the pager.
4674    // `stored_complete` is trivially true with nothing stored, so without this the
4675    // first sync would cache a partial authority as its own baseline.
4676    if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4677        authority_changed |= stored != authority.roles;
4678        crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4679    }
4680    // Cache the folded invite Registry so Public/Private stays a sync LOCAL read
4681    // (v1 parity — `invite_registry` is the column every caller reads). Gated like
4682    // the roster: a truncated or gapped window folds an empty registry out of mere
4683    // absence, and persisting that under-states Public — the unsafe direction, since
4684    // it leaves a live link open behind a ban.
4685    if !truncated && !authority.gapped && !fold.gapped {
4686        if let Ok(owner) = community.owner() {
4687            let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4688            let live = flatten_link_sets(&sets);
4689            let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4690            before.sort();
4691            if before != live {
4692                crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4693                authority_changed = true;
4694            }
4695            // The per-creator split drives "X has N active invite links" and the
4696            // first-link-flips-Public confirm; it lives in its own table.
4697            crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4698        }
4699    }
4700    // Folded Pin List heads (CORD-04 §7): raw content per channel. The write
4701    // itself is monotonic on version (atomic in the statement), so a stale
4702    // window racing a publish echo can never regress a newer held head.
4703    for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
4704        match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
4705            Ok(true) => {
4706                crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
4707                crate::emit_event(
4708                    "community_pins_updated",
4709                    &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
4710                );
4711                note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
4712            }
4713            Ok(false) => {}
4714            Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
4715        }
4716    }
4717    // Roster/banlist moves are invisible in the returned community (they live in
4718    // their own columns), so callers that key a refresh off `updated` would never
4719    // repaint a promote/demote/ban. Announce from the single fold point — it covers
4720    // realtime, boot catch-up and manual sync alike.
4721    if authority_changed {
4722        crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4723    }
4724    // Owner-side silent Admin widening (PIN_MESSAGES): after the roster has
4725    // folded, so the check reads the settled roles. Once per community per
4726    // process — the fold runs constantly and the upgrade is a one-shot.
4727    {
4728        static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
4729        let first = UPGRADED
4730            .lock()
4731            .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
4732            .unwrap_or(false);
4733        if first {
4734            let _ = upgrade_admin_role_pin_bit(transport, community).await;
4735        }
4736    }
4737    match fold.updated {
4738        Some(u) => {
4739            crate::db::community::save_community_v2(&u)?;
4740            Ok(Some(u))
4741        }
4742        None => Ok(None),
4743    }
4744}
4745
4746/// Control-follow paging bounds: enough depth to re-anchor a long-offline floor
4747/// (H1/M8 refetch) without letting a flooding relay stall the follow queue.
4748///
4749/// Nearly free to raise: both follow loops exit the moment the fold stops being
4750/// gapped, so the cap only binds when something is genuinely missing — exactly
4751/// when paging further is what's wanted. The old ceiling of 4 (~2k editions) sat
4752/// under a plane that 100 roles + 400 grants already outgrows before counting
4753/// superseded versions, which accumulate until a compaction retires them.
4754const FOLLOW_MAX_PAGES: usize = 32;
4755const FOLLOW_PAGE: usize = 500;
4756/// Page ceiling for a COMPACTION read (CORD-06 §3: a Refounder that cannot fold
4757/// every Control Event must abort). Far above any real plane, but plane depth is
4758/// attacker-controlled — any member holds the key that mints wraps — so the read
4759/// is bounded and reports coming up short rather than compacting a partial view.
4760const COMPACT_MAX_PAGES: usize = 512;
4761
4762/// A folded control head to persist as the per-entity refuse-downgrade floor.
4763#[derive(Clone)]
4764struct FoldedHead {
4765    entity_hex: String,
4766    version: u64,
4767    self_hash: [u8; 32],
4768    inner_id: [u8; 32],
4769}
4770
4771/// The outcome of a floor-aware control fold: the updated community (if content
4772/// changed), the heads to persist as the new floor (returned even when content is
4773/// unchanged, so the floor still seeds/advances), and whether any TRACKING entity
4774/// hit an unresolvable gap — the caller's signal to page older history and re-fold
4775/// (CORD-04 H1/M8's refetch).
4776struct ControlFold {
4777    updated: Option<CommunityV2>,
4778    heads: Vec<FoldedHead>,
4779    gapped: bool,
4780    /// Folded Pin List heads to persist:
4781    /// `(channel_hex, raw content, version, author_npub, created_at)`.
4782    /// Raw carried bytes on purpose — republishing must not re-serialize.
4783    pins_persist: Vec<(String, String, u64, String, u64)>,
4784}
4785
4786/// Per-entity floor: `(version, self_hash, inner_id)` of the committed head.
4787type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4788
4789/// Fold owner-authored control editions into an updated community using the
4790/// PERSISTED per-entity version floor (refuse-downgrade). Per entity, fold with
4791/// [`version::fold`]`(floor, floor_hash)`:
4792///   - ANCHORED: adopt the chain-verified head. A `gap` ABOVE it (withheld middles)
4793///     doesn't block the verified prefix — refuse-downgrade holds for everything
4794///     applied — but flags `gapped` so the caller pages for the rest.
4795///   - UNANCHORED under a held floor: one legitimate cause is a same-version owner
4796///     fork AT the floor whose deterministic winner (lower inner id; a NULL held id
4797///     is always replaceable, mirroring v1's `decide()`) isn't our held edition —
4798///     the floor CONVERGES to the winner and the chain re-anchors on it, so every
4799///     client lands on the same head where a hash-strict floor would wedge forever.
4800///     Anything else is withholding → fail closed + `gapped`.
4801///   - BOOTSTRAPPING (`floor == 0` — a fresh joiner, or a fresh epoch after a
4802///     Refounding, since the caller epoch-filters the floor) takes the highest
4803///     signed head (author already owner-filtered).
4804/// This matches CORD-04 §1 and mirrors v1's `fold_roster`. Epoch-filtering makes a
4805/// compaction at a new epoch auto-bootstrap, converging with Armada's acceptance of
4806/// a compacted head across a dangling `prev` (Armada doesn't persist a floor, so a
4807/// Vector floor only makes Vector STRICTER locally — no wire change, honest-case
4808/// convergence preserved).
4809fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4810    use crate::community::roles::Permissions;
4811    use std::collections::BTreeMap;
4812
4813    let owner_hex = community.owner().ok().map(|o| o.to_hex());
4814
4815    let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4816    for e in editions {
4817        groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4818    }
4819
4820    let mut out = community.clone();
4821    let mut changed = false;
4822    let mut heads = Vec::new();
4823    let mut gapped = false;
4824    let mut pins_persist = Vec::new();
4825    // Pin List eids are one-way HKDF locators, so attribution runs the other
4826    // direction: precompute every known channel's locator. An eid matching no
4827    // channel folds nothing this round — once the channel's metadata lands, the
4828    // next fold attributes it (editions re-fold from the window each sync).
4829    let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
4830        .channels
4831        .iter()
4832        .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
4833        .collect();
4834    for ((vsk_code, eid), group) in &groups {
4835        // This fold applies three entities: community metadata (eid ==
4836        // community_id), channel metadata, and per-channel Pin Lists. A vsk-2
4837        // whose eid equals the community id is excluded — the floor row keys on
4838        // the entity alone, so it would share (and corrupt) the metadata
4839        // chain's floor.
4840        let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4841        let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4842        let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
4843        if !is_meta && !is_channel && pins_channel.is_none() {
4844            continue;
4845        }
4846        // Authority gate (CORD-04 §5): only editions whose author CURRENTLY holds the
4847        // entity's management bit are eligible. Pre-filtering before the fold means a
4848        // demoted admin's (possibly higher-version) edition can't be the head; the
4849        // highest AUTHORIZED head wins. The owner is supreme.
4850        let required = if is_meta {
4851            Permissions::MANAGE_METADATA
4852        } else if is_channel {
4853            Permissions::MANAGE_CHANNELS
4854        } else {
4855            Permissions::PIN_MESSAGES
4856        };
4857        let authed: Vec<&ParsedEdition> = group
4858            .iter()
4859            .copied()
4860            .filter(|e| {
4861                let author = e.author.to_hex();
4862                // A banned npub's edits are dropped (CORD-04 §4), even if they still
4863                // held a bit via a not-yet-stripped grant.
4864                !authority.banned.contains(&author)
4865                    && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4866                    // …and the CORD-04 §5 sync floor. Resolved against the Grant heads
4867                    // this same fold settled, so it works on a bootstrap where no
4868                    // persisted head exists yet.
4869                    && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4870            })
4871            .collect();
4872        if authed.is_empty() {
4873            continue;
4874        }
4875        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4876        let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4877        let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4878        gapped |= entity_gapped;
4879        let Some(hi) = hi else { continue };
4880
4881        let head = authed[hi];
4882        heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4883        if is_meta {
4884            if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4885                changed |= apply_community_metadata(&mut out, meta);
4886            }
4887        } else if is_channel {
4888            if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4889                // vsk-2 carries no community binding (shared v1 grammar); a same-owner
4890                // cross-community replay can inject a phantom PUBLIC channel (bounded:
4891                // root-scoped key, eids don't collide). Binding is a deferred wire change.
4892                changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4893            }
4894        } else if let Some(channel_hex) = pins_channel {
4895            // The RAW carried content, cap-violations included: readers judge
4896            // those (read as empty), and a re-serialization here would break the
4897            // byte cap's meaning and every republish's fidelity.
4898            use nostr_sdk::prelude::ToBech32;
4899            let author_npub = head
4900                .author
4901                .to_bech32()
4902                .unwrap_or_else(|_| head.author.to_hex());
4903            pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
4904        }
4905    }
4906    ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
4907}
4908
4909/// Fold one entity's editions against its persisted floor into a head index (into the
4910/// input slice) plus whether a TRACKING gap was hit (the caller pages older history).
4911/// Encapsulates the W2 refuse-downgrade policy: bootstrap at floor 0 (highest signed
4912/// head, what Armada shows across a compaction's dangling prev); adopt the chain-
4913/// anchored head, paging on an upper gap; converge a same-version fork at the floor to
4914/// the lower-inner-id winner; and fail closed otherwise.
4915fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4916    let floor_v = floor.map(|f| f.0).unwrap_or(0);
4917    if floor_v == 0 {
4918        return (version::bootstrap_head(fold_eds, 0), false);
4919    }
4920    let floor_hash = floor.map(|f| &f.1);
4921    let held_inner = floor.and_then(|f| f.2);
4922    let result = version::fold(fold_eds, floor_v, floor_hash);
4923    if result.anchored {
4924        return (result.head, result.gap); // verified prefix; page any upper gap.
4925    }
4926    if result.head.is_none() && !result.gap {
4927        return (None, false); // everything below floor — a stale relay, no paging.
4928    }
4929    // Unanchored under a held floor: converge a same-version fork at the floor to its
4930    // deterministic winner (lower inner id; a NULL held id is always replaceable),
4931    // else fail closed.
4932    let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4933    let win_hash = match fork {
4934        Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4935        _ => return (None, true), // detached from our committed head → withholding.
4936    };
4937    let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4938    if !re.anchored {
4939        return (None, true);
4940    }
4941    (re.head, re.gap)
4942}
4943
4944/// The folded, delegation-AUTHORIZED control-plane authority (CORD-04): the roster
4945/// (roles + grants, owner-seeded fixpoint), the enforced banlist, and the
4946/// role/grant/banlist heads to persist as refuse-downgrade floors. The owner is
4947/// recomputed from the self-certifying community_id at each use.
4948struct AuthoritySet {
4949    roles: crate::community::roles::CommunityRoles,
4950    banned: std::collections::BTreeSet<String>,
4951    heads: Vec<FoldedHead>,
4952    gapped: bool,
4953    /// The authorized banlist `(content, version)` to persist when an authorized head
4954    /// advanced the floor. `None` when the banlist was retained (no new authorized
4955    /// head) or is empty — the caller then leaves the stored banlist untouched.
4956    banlist_persist: Option<(Vec<String>, u64)>,
4957    /// Ban HISTORY: npub hex → `created_at` (secs) of the newest authorized edition that
4958    /// named them, across every edition in the window rather than just the head. Outlives
4959    /// the ban itself so an un-ban can't resurrect a phantom (see [`fold_members`]).
4960    banned_at: std::collections::BTreeMap<String, u64>,
4961}
4962
4963impl AuthoritySet {
4964    /// Bootstrap authority for a community with no roster editions folded yet: only
4965    /// the owner is authorized (supreme), nobody banned.
4966    fn owner_only() -> Self {
4967        AuthoritySet {
4968            roles: Default::default(),
4969            banned: Default::default(),
4970            heads: vec![],
4971            gapped: false,
4972            banlist_persist: None,
4973            banned_at: Default::default(),
4974        }
4975    }
4976}
4977
4978/// Fold the roster/banlist entities (vsk 1/3/4) from the control editions into the
4979/// delegation-AUTHORIZED roster + enforced banlist (CORD-04 §2-§5). Each entity binds
4980/// to its coordinate (role at role_id, grant at grant_locator(cid, member), banlist at
4981/// banlist_locator(cid)); a content whose coordinate doesn't match is dropped. Roles
4982/// cap at the 100 lowest role_ids, a member at 64 roles, the banlist at 500. The
4983/// banlist is enforced only if its head's signer held BAN in the authorized roster.
4984fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4985    use crate::community::roles::Permissions;
4986    use std::collections::BTreeMap;
4987
4988    let cid = community.id();
4989    let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4990    let owner = community.owner().ok();
4991    let owner_hex = owner.map(|o| o.to_hex());
4992    let banlist_eid = super::derive::banlist_locator(cid);
4993    let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4994
4995    let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4996    for e in editions {
4997        if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4998            groups.entry(e.entity_id).or_default().push(e);
4999        }
5000    }
5001
5002    // Per-entity CANDIDATE lists — every ≥floor edition of a role/grant, highest
5003    // version first (lowest inner-id as the deterministic tiebreak). CORD-04 §1: an
5004    // edition whose signer isn't authorized is SIMPLY DROPPED and the fold continues
5005    // to the next candidate, so a forged higher-version edition can't suppress the
5006    // authorized head beneath it (the author-blind collapse-to-one-head it replaces
5007    // let any member vanish a role or a member's grant). `gapped` (drives older-
5008    // paging) stays fold_head's per-entity flag.
5009    let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5010    let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5011    let mut gapped = false;
5012
5013    for (eid, group) in &groups {
5014        // The banlist is folded author-aware AFTER the roster is known (below).
5015        if *eid == banlist_eid {
5016            continue;
5017        }
5018        let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5019        let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
5020        let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5021        gapped |= entity_gapped;
5022        let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
5023
5024        for p in group {
5025            // Refuse-downgrade: never consider an edition below the persisted floor.
5026            if p.version < floor_v {
5027                continue;
5028            }
5029            let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
5030            match p.vsk.as_str() {
5031                vsk::ROLE => {
5032                    // Bind: the content's role_id IS the coordinate; position 0 is the owner's.
5033                    if let Some(role) = super::roles::parse_role_content(&p.content) {
5034                        if role.role_id == entity_hex && role.position != 0 {
5035                            role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
5036                        }
5037                    }
5038                }
5039                vsk::GRANT => {
5040                    if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
5041                        if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
5042                            if super::derive::grant_locator(cid, &member) == *eid {
5043                                grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
5044                                grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
5045                            }
5046                        }
5047                    }
5048                }
5049                _ => {}
5050            }
5051        }
5052    }
5053    for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
5054        cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
5055    }
5056
5057    let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
5058    // Preliminary roster (bans not yet applied) — the authority view the banlist head
5059    // is judged against.
5060    let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
5061
5062    // Banlist (CORD-04 §4), folded AUTHORITY-aware so its two anti-roster hazards are
5063    // both closed:
5064    //   - head selection: the head is the highest version whose author CURRENTLY holds
5065    //     BAN — an unauthorized higher-version edition can't erase existing bans
5066    //     (fail-open), and the floor never advances to one;
5067    //   - per-target: each entry is kept only if the author STRICTLY OUTRANKS that
5068    //     target (`can_act_on_member` — an admin can't ban a peer/superior, and the
5069    //     owner is unbannable);
5070    //   - withholding: when no authorized head is served, the persisted banlist is
5071    //     RETAINED (an anti-roster must not un-ban on a relay withholding the ban).
5072    let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5073    // An ALREADY-banned npub can't author the banlist (a banned member vanishes, §4), or
5074    // a BAN-holder whose grant-strip hasn't yet folded could publish a list omitting their
5075    // OWN ban to un-ban themselves (removals aren't outrank-checked). Exclude them from
5076    // head eligibility, not just from the roster.
5077    let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
5078    let banlist_authored: Vec<&ParsedEdition> = groups
5079        .get(&banlist_eid)
5080        .map(|g| {
5081            g.iter()
5082                .copied()
5083                .filter(|e| {
5084                    let ah = e.author.to_hex();
5085                    !banned_authors.contains(ah.as_str())
5086                        && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
5087                        && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
5088                })
5089                .collect()
5090        })
5091        .unwrap_or_default();
5092    // Ban history for phantom suppression: the newest AUTHORIZED edition naming each npub,
5093    // over EVERY candidate rather than only the head — an un-ban replaces the head, so the
5094    // head alone forgets the ban that the suppression exists to remember. The owner is
5095    // skipped: they are never bannable, and a moderator listing them must not durably
5096    // suppress them past the un-ban.
5097    let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
5098    for p in &banlist_authored {
5099        for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
5100            if owner_hex.as_deref() == Some(t.as_str()) {
5101                continue;
5102            }
5103            let slot = banned_at.entry(t).or_insert(0);
5104            *slot = (*slot).max(p.created_at);
5105        }
5106    }
5107    let mut banlist_persist: Option<(Vec<String>, u64)> = None;
5108    let mut banlist_head: Option<FoldedHead> = None;
5109    let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
5110        persisted_banned.into_iter().collect()
5111    } else {
5112        let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
5113        let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
5114        gapped |= g;
5115        match hi {
5116            Some(hi) => {
5117                let head = banlist_authored[hi];
5118                let ah = head.author.to_hex();
5119                let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
5120                    .unwrap_or_default()
5121                    .into_iter()
5122                    .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
5123                    .take(super::roles::MAX_BANLIST)
5124                    .collect();
5125                banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
5126                banlist_persist = Some((list.clone(), head.version));
5127                list.into_iter().collect()
5128            }
5129            None => persisted_banned.into_iter().collect(),
5130        }
5131    };
5132
5133    // Final roster (CORD-04 §4: a banned npub vanishes — every edition it authored is
5134    // dropped, and a grant TO a banned member carries no rank). Re-run selection with
5135    // the banned set excluded so a banned admin loses authority.
5136    let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
5137    if let Some(bh) = banlist_head {
5138        heads.push(bh);
5139    }
5140
5141    // Cap the AUTHORIZED community at the 100 lowest role_ids — applied AFTER
5142    // authorization, so an attacker's unauthorized roles can't consume cap slots and
5143    // evict a legitimate one (the pre-authorize cap they replace let 100 forged low-id
5144    // roles empty the roster).
5145    if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
5146        authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
5147        authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
5148        let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
5149        authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
5150        authorized.grants.retain(|g| !g.role_ids.is_empty());
5151    }
5152
5153    AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
5154}
5155
5156/// One candidate edition of a role/grant entity — the pool [`select_authorized`]
5157/// draws the highest AUTHORIZED head from (exactly one of `role`/`grant` is set).
5158struct AuthorityCand {
5159    role: Option<crate::community::roles::Role>,
5160    grant: Option<crate::community::roles::MemberGrant>,
5161    author: PublicKey,
5162    head: FoldedHead,
5163    /// The `vac` this edition carried (CORD-04 §5). `None` for an owner edition
5164    /// (supreme, cites nothing) or an uncited one — the latter is refused.
5165    citation: Option<crate::community::edition::AuthorityCitation>,
5166}
5167
5168/// CORD-04 §5 sync floor, resolved against the heads THIS fold pass has accepted.
5169///
5170/// Deliberately not the persisted-head helper the kick/hide paths use: this IS the
5171/// pass that establishes those heads, so an external floor would refuse every
5172/// non-owner edition on a bootstrap and the roster could never fold. Same rule the
5173/// spec gives for a dangling `prev` across a Refounding — a fresh joiner takes the
5174/// authority-verified head as its baseline, a tracking client fails closed per
5175/// entity — applied to the citation instead of the chain link.
5176fn citation_ok_in_fold(
5177    cid: &crate::community::CommunityId,
5178    heads: &[FoldedHead],
5179    owner_hex: Option<&str>,
5180    author: &PublicKey,
5181    citation: Option<&crate::community::edition::AuthorityCitation>,
5182) -> bool {
5183    let actor_hex = author.to_hex();
5184    if owner_hex == Some(actor_hex.as_str()) {
5185        return true;
5186    }
5187    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
5188    let as_entity: Vec<crate::community::roster::EntityHead> = heads
5189        .iter()
5190        .map(|h| crate::community::roster::EntityHead {
5191            entity_hex: h.entity_hex.clone(),
5192            version: h.version,
5193            self_hash: h.self_hash,
5194            inner_id: h.inner_id,
5195            citation: None,
5196        })
5197        .collect();
5198    crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
5199}
5200
5201/// The owner-seeded delegation fixpoint (CORD-04 §1/§2), author-AWARE: per entity it
5202/// takes the highest-version candidate whose author is authorized to author it under
5203/// the roster resolved SO FAR, dropping unauthorized higher versions rather than
5204/// vanishing the entity. Authority resolves outward from the owner (proven by
5205/// `community_id`, never a Role), and the strict-outrank rule (no edition at/above its
5206/// signer's own position) keeps the fixpoint monotone, so it converges. Returns the
5207/// authorized roster plus the per-entity heads of the SELECTED editions (the floor
5208/// advances only to authorized heads — an unauthorized forgery never poisons it).
5209fn select_authorized(
5210    cid: &crate::community::CommunityId,
5211    role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5212    grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5213    owner_hex: Option<&str>,
5214    excluded: &std::collections::BTreeSet<String>,
5215) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
5216    use crate::community::roles::{CommunityRoles, Permissions};
5217    let mut accepted = CommunityRoles::default();
5218    let mut heads: Vec<FoldedHead> = Vec::new();
5219    // Jacobi iteration: authority propagates one delegation level per round, so a
5220    // generous multiple of the entity count is an ample bound. Non-convergence (never
5221    // seen for an owner-rooted chain) falls through fail-safe: only authorized editions
5222    // are ever selected.
5223    let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
5224    for _ in 0..bound {
5225        let mut next = CommunityRoles::default();
5226        let mut next_heads: Vec<FoldedHead> = Vec::new();
5227
5228        for cands in role_cands.values() {
5229            // Two gates, not one (CORD-04 §2). Minting at a position you outrank
5230            // is necessary but not sufficient: an edition REPLACES the entity, so
5231            // the author must also outrank the position standing before it.
5232            // Without that, an admin at position 5 rewrites the position-1 role
5233            // to position 9 — every check passes, since 9 is beneath them — and
5234            // a role that outranked them is now beneath them, along with everyone
5235            // holding it. Rank inversion by republish.
5236            //
5237            // The chain is replayed ASCENDING so each version is judged against
5238            // the position its own predecessor established, then the highest
5239            // admissible version wins (candidates arrive version-DESC, forks
5240            // broken by lowest inner_id — preserved by walking version groups).
5241            let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
5242            let mut standing: Option<u32> = None;
5243            let mut i = cands.len();
5244            while i > 0 {
5245                let hi = i;
5246                let ver = cands[i - 1].head.version;
5247                while i > 0 && cands[i - 1].head.version == ver {
5248                    i -= 1;
5249                }
5250                // One winner per version: fork siblings can't sidestep the gate.
5251                for c in cands[i..hi].iter().rev() {
5252                    let Some(role) = &c.role else { continue };
5253                    let ah = c.author.to_hex();
5254                    if excluded.contains(&ah) || role.position == 0 {
5255                        continue;
5256                    }
5257                    if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
5258                        continue;
5259                    }
5260                    if let Some(prev) = standing {
5261                        if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
5262                            continue;
5263                        }
5264                    }
5265                    if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5266                        continue;
5267                    }
5268                    admissible.insert(c.head.self_hash);
5269                    standing = Some(role.position);
5270                    break;
5271                }
5272            }
5273            for c in cands {
5274                let Some(role) = &c.role else { continue };
5275                if !admissible.contains(&c.head.self_hash) {
5276                    continue;
5277                }
5278                next.roles.push(role.clone());
5279                next_heads.push(c.head.clone());
5280                break; // highest admissible candidate for this entity
5281            }
5282        }
5283        for cands in grant_cands.values() {
5284            for c in cands {
5285                let Some(grant) = &c.grant else { continue };
5286                let ah = c.author.to_hex();
5287                if excluded.contains(&ah) || excluded.contains(&grant.member) {
5288                    continue;
5289                }
5290                // The granter must outrank every granted role (resolved against the
5291                // accepted roster) AND the member — the escalation defense (CORD-04 §2).
5292                let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5293                let Some(positions) = positions else { continue };
5294                if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5295                    continue;
5296                }
5297                if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5298                    && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5299                {
5300                    // Record the head even for an EMPTY grant (a revoke is a real chain
5301                    // advance a completeness check must see), but don't carry the husk
5302                    // into the roster.
5303                    next_heads.push(c.head.clone());
5304                    if !grant.role_ids.is_empty() {
5305                        next.grants.push(grant.clone());
5306                    }
5307                    break;
5308                }
5309            }
5310        }
5311
5312        let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5313        accepted = next;
5314        heads = next_heads;
5315        if converged {
5316            break;
5317        }
5318    }
5319    (accepted, heads)
5320}
5321
5322/// Apply a folded community-metadata head. Relays only overwrite when the edition
5323/// carries a non-empty list (a metadata edition that omits relays must not blank
5324/// the working set). Returns whether anything changed.
5325fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5326    let mut changed = false;
5327    if out.name != meta.name {
5328        out.name = meta.name;
5329        changed = true;
5330    }
5331    if out.description != meta.description {
5332        out.description = meta.description;
5333        changed = true;
5334    }
5335    // Icon/banner apply verbatim, None included — an edition is the full
5336    // document, so an absent image IS a removal (editors preserve via
5337    // `CommunityV2::metadata()`).
5338    if out.icon != meta.icon {
5339        out.icon = meta.icon;
5340        changed = true;
5341    }
5342    if out.banner != meta.banner {
5343        out.banner = meta.banner;
5344        changed = true;
5345    }
5346    // Client-extensible + unknown fields ride the fold verbatim so our own
5347    // editions can carry them forward (CORD-02 §6).
5348    if out.meta_custom != meta.custom {
5349        out.meta_custom = meta.custom;
5350        changed = true;
5351    }
5352    if out.meta_extra != meta.extra {
5353        out.meta_extra = meta.extra;
5354        changed = true;
5355    }
5356    // CAP on the way in. `cap_relays` is the truncate-on-read invariant for every
5357    // other construction boundary, and the fold is a boundary like any other: an
5358    // authorized editor is not a trusted one, and an oversize list costs every
5359    // member a fan-out on each publish and the slowest of N on each fetch
5360    // (CORD-02 §6 makes trimming explicitly a client's call). Compare against the
5361    // CAPPED list too — against the raw one, an oversize edition never compares
5362    // equal, so every fold would report a change and re-save forever.
5363    let relays = crate::community::cap_relays(meta.relays);
5364    if !relays.is_empty() && out.relays != relays {
5365        out.relays = relays;
5366        changed = true;
5367    }
5368    changed
5369}
5370
5371/// Apply a folded channel-metadata head: delete removes the channel, a rename
5372/// updates an existing one, a brand-new PUBLIC channel is added, and a brand-new
5373/// PRIVATE one is recorded KEYLESS (unreadable until its key arrives over the
5374/// rekey plane or a fresh bundle). Returns whether anything changed.
5375fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5376    let deleted = meta.deleted.unwrap_or(false);
5377    if deleted {
5378        let before = out.channels.len();
5379        out.channels.retain(|c| c.id.0 != id.0);
5380        return out.channels.len() != before;
5381    }
5382    match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5383        Some(existing) => {
5384            let mut changed = false;
5385            if existing.name != meta.name {
5386                existing.name = meta.name;
5387                changed = true;
5388            }
5389            // vsk-2 fields Vector doesn't drive still fold + persist, so a later
5390            // local edit republishes them instead of wiping (CORD-02 §6).
5391            if existing.voice != meta.voice {
5392                existing.voice = meta.voice;
5393                changed = true;
5394            }
5395            if existing.meta_custom != meta.custom {
5396                existing.meta_custom = meta.custom;
5397                changed = true;
5398            }
5399            if existing.meta_extra != meta.extra {
5400                existing.meta_extra = meta.extra;
5401                changed = true;
5402            }
5403            // The owner's edition authoritatively declares visibility. A channel the
5404            // owner marks PUBLIC must derive from the root (key = None) — this heals a
5405            // bundle-time misclassification where an attacker set a public channel's
5406            // grant key to their own, silently addressing it at a plane only they read.
5407            // Public → private CONVERSION is DEFERRED: the flip is IGNORED here (the
5408            // record stays public) until the convert flow (key mint + cursor rebase
5409            // to the conversion's channel epoch) lands — the send side refuses to
5410            // publish one, and a foreign client's conversion won't move us.
5411            if !meta.private && (existing.private || existing.key.is_some()) {
5412                existing.private = false;
5413                existing.key = None;
5414                changed = true;
5415            }
5416            changed
5417        }
5418        None if !meta.private => {
5419            // A public channel derives its Chat Plane from the community_root at the
5420            // current root epoch (key = None); its stored epoch mirrors the root.
5421            out.channels.push(ChannelV2 {
5422                id,
5423                name: meta.name,
5424                private: false,
5425                key: None,
5426                epoch: out.root_epoch,
5427                voice: meta.voice,
5428                meta_custom: meta.custom,
5429                meta_extra: meta.extra,
5430            });
5431            true
5432        }
5433        None => {
5434            // A brand-new PRIVATE channel: record it KEYLESS at epoch 0 (the root
5435            // generation — CORD-03 §2 numbers the first private key epoch 1). The
5436            // epoch then doubles as [`follow_rekeys`]' scan cursor. Until a rotation
5437            // delivers a key, every read/send/subscribe path skips the channel; the
5438            // root-fallback in `channel_secret` is never taken for it.
5439            out.channels.push(ChannelV2 {
5440                id,
5441                name: meta.name,
5442                private: true,
5443                key: None,
5444                epoch: Epoch(0),
5445                voice: meta.voice,
5446                meta_custom: meta.custom,
5447                meta_extra: meta.extra,
5448            });
5449            true
5450        }
5451    }
5452}
5453
5454// ── Live rekey-follow (CORD-06 §2/§3) ────────────────────────────────────────
5455
5456/// The outcome of a rekey-follow pass.
5457pub struct RekeyFollow {
5458    /// The community after adopting every rotation it could catch up on, or `None`
5459    /// if nothing advanced.
5460    pub updated: Option<CommunityV2>,
5461    /// A base rotation removed us — the caller tears the local hold down (the
5462    /// updated community is not persisted in that case).
5463    pub self_removed: bool,
5464    /// An owner tombstone sits on the dissolved plane (CORD-02 §9) — the local
5465    /// flag is already set; the caller surfaces the death and stops following.
5466    pub dissolved: bool,
5467}
5468
5469/// The most archived base roots a channel-rekey lookup fans across per step. A
5470/// standalone rekey rides the minter's then-current root and a removal's rides the
5471/// PRIOR root (CORD-06 §3), so a follower whose base already advanced must look
5472/// back. A channel stranded DEEPER than this (its next-epoch crate addressed under
5473/// an older root than the fan reaches) only heals via a fresh invite bundle — the
5474/// walk is strictly sequential, so a later rotation can't be reached either.
5475const MAX_ADDRESSING_ROOTS: usize = 8;
5476
5477/// The base roots a channel rekey may be addressed under, freshest first: the
5478/// current root plus the archived priors, capped at [`MAX_ADDRESSING_ROOTS`].
5479/// CORD-06 D2: a removal-forced channel rekey rides the PRIOR root — so the
5480/// follower's fetch fan ([`follow_rekeys`]) and the stream-auth registration
5481/// (`streamauth::register_community`) MUST cover the SAME set. A plane the
5482/// fetch addresses but auth never registered is invisible on an AUTH-gating
5483/// relay: the REQ is CLOSED, the rotation crate never arrives, and the channel
5484/// wedges at its old epoch while the base advances.
5485pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5486    let mut roots: Vec<[u8; 32]> = vec![cur_root];
5487    let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5488        .unwrap_or_default();
5489    archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5490    for (_, r) in archived {
5491        if !roots.contains(&r) {
5492            roots.push(r);
5493        }
5494    }
5495    roots.truncate(MAX_ADDRESSING_ROOTS);
5496    roots
5497}
5498
5499/// Follow rekeys for a held community: advance the base (root) epoch and each
5500/// Private channel's epoch as far as authorized rotations allow, adopting the
5501/// fresh key we're still a recipient of at each step and dropping a scope we've
5502/// been removed from. Persists the result. Called when a rekey wrap arrives in
5503/// realtime so a long-running bot keeps decrypting after a rotation instead of
5504/// going silent.
5505///
5506/// **Authority (CORD-06 §Authority):** a BASE rotation is honored from the owner
5507/// only — the deliberate mirror of the owner-only Refounding send (a non-owner's
5508/// ban silences + strips; the read-cut is the owner's). A CHANNEL rotation is
5509/// honored from the owner or a `MANAGE_CHANNELS` holder under the PERSISTED
5510/// roster (folded + persisted by `follow_control`), minus the banlist — so an
5511/// admin-created private channel keys up on every member.
5512///
5513/// **Addressing fans across held base roots:** each channel step queries its
5514/// next-epoch rekey address under the current root AND the archived prior roots,
5515/// so a base adopt landing before a Refounding's prior-root-addressed channel
5516/// rekeys (or before a creation delivery minted under an older root) can't
5517/// strand the channel.
5518///
5519/// **Continuity + fork resolution are spec-strict:** a rotation must extend the
5520/// exact `(epoch, key)` I hold, one epoch at a time; a same-epoch fork resolves
5521/// by the lexicographically lowest new key ([`rekey::lowest_key_winner`]), so
5522/// every follower converges. An incomplete rotation (a missing chunk) never
5523/// concludes removal — it just waits. A KEYLESS channel (announced by vsk-2, key
5524/// not yet delivered) holds no chain, so continuity is vacuous for it (CORD-06
5525/// §2: "a convergence check, not a secrecy mechanism") — authority is its
5526/// boundary; its epoch is the scan cursor, advancing past complete rotations
5527/// that exclude us so the walk converges on the channel's current epoch.
5528/// Diagnostic: run the base-rotation fetch+parse pipeline for a wedged community
5529/// and report, per rotation found at the next-epoch base plane, WHY
5530/// `follow_rekeys` did or didn't adopt it — the exact `advance_scope` gate that
5531/// tripped. Read-only. Every rotator/owner is a PUBLIC key; no secret material
5532/// is returned.
5533#[cfg(debug_assertions)]
5534pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5535    transport: &T,
5536    community: &CommunityV2,
5537) -> Result<serde_json::Value, String> {
5538    let my_xonly = me_pk()?.to_bytes();
5539    let owner = community.owner()?;
5540    let owner_hex = owner.to_hex();
5541    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5542    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5543    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5544    let held_epoch = community.root_epoch;
5545    let held_key = community.community_root;
5546    let next = Epoch(held_epoch.0.saturating_add(1));
5547    let group = base_rekey_group_key(&held_key, community.id(), next);
5548    let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5549    let rotations = rekey::collect_rotations(&chunks);
5550
5551    let reports: Vec<serde_json::Value> = rotations
5552        .iter()
5553        .map(|r| {
5554            let rotator_is_owner = r.rotator == owner;
5555            // CORD-06 §Authority: a Refounding is authorized by BAN in the folded
5556            // Roster, not owner-identity — report that gate, not just owner-equality.
5557            let rotator_authorized = rotator_is_owner
5558                || (!banned.contains(&r.rotator.to_hex())
5559                    && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5560            let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5561            let epoch_ok = r.new_epoch.0 == next.0;
5562            let complete = r.is_complete();
5563            let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5564            let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5565            // Is the OWNER a recipient? A non-owner Refounding that drops the owner
5566            // is a takeover attempt — this tells whether an "owner must be kept"
5567            // adopt-block would be safe here (it would falsely reject a legitimate
5568            // rotation that happened to exclude the owner).
5569            let owner_kept = r.rotator == owner
5570                || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5571            // The exact reason follow_rekeys skipped/rejected this rotation, in gate order.
5572            let verdict = if !rotator_authorized {
5573                "REJECTED: rotator holds no BAN authority in the folded roster"
5574            } else if !scope_ok {
5575                "REJECTED: scope is not Root"
5576            } else if !epoch_ok {
5577                "REJECTED: new_epoch != held+1"
5578            } else if !complete {
5579                "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5580            } else if continuity != "Extends" {
5581                "REJECTED: continuity does not extend my held root (FORK/GAP)"
5582            } else if has_my_blob {
5583                "ADOPT: authorized + complete + continuous + my blob present"
5584            } else {
5585                "REMOVED: complete authorized rotation with no blob for me"
5586            };
5587            serde_json::json!({
5588                "rotator": r.rotator.to_hex(),
5589                "rotator_is_recorded_owner": rotator_is_owner,
5590                "rotator_authorized_ban": rotator_authorized,
5591                "scope_is_root": scope_ok,
5592                "new_epoch": r.new_epoch.0,
5593                "prev_epoch": r.prev_epoch.0,
5594                "declared_chunks": r.declared_chunks,
5595                "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5596                "is_complete": complete,
5597                "continuity_vs_held_root": continuity,
5598                "my_blob_present": has_my_blob,
5599                "owner_kept": owner_kept,
5600                "blob_count": r.blobs.len(),
5601                "verdict": verdict,
5602            })
5603        })
5604        .collect();
5605
5606    Ok(serde_json::json!({
5607        "recorded_owner": owner.to_hex(),
5608        "held_root_epoch": held_epoch.0,
5609        "probing_next_epoch": next.0,
5610        "base_plane_pk": group.pk_hex(),
5611        "raw_chunks_parsed": chunks.len(),
5612        "rotations_found": rotations.len(),
5613        "rotations": reports,
5614    }))
5615}
5616
5617pub async fn follow_rekeys<T: Transport + ?Sized>(
5618    transport: &T,
5619    community: &CommunityV2,
5620    session: &SessionGuard,
5621) -> Result<RekeyFollow, String> {
5622    // Death wins every race (CORD-02 §9): a dissolved community honors no epoch advance
5623    // past its tombstone — don't adopt a rotation into a grave.
5624    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5625    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5626        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5627    }
5628    // An offline member must also LEARN of a death: the tombstone rides its own
5629    // public plane, which the live sub watches but no catch-up fetch touched —
5630    // without this, a member who slept through a dissolution follows (and posts
5631    // into) a grave forever. Fail-open on transport failure: availability is
5632    // never death.
5633    if is_dissolved(transport, community).await {
5634        if session.is_valid() {
5635            let _ = crate::db::community::set_community_dissolved(&cid_hex);
5636        }
5637        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5638    }
5639    let signer = crate::signer::active_signer()?;
5640    let my_pk = me_pk()?;
5641    let my_xonly = my_pk.to_bytes();
5642    let owner = community.owner()?;
5643    let owner_hex = owner.to_hex();
5644    let mut cur = community.clone();
5645    let mut changed = false;
5646
5647    // The rotator/admissibility gates read the PERSISTED roster (folded by a prior
5648    // follow_control; the worker folds control right after this rekey pass). This
5649    // is "one pass late" for the rotator-AUTHORIZATION direction (a newly-granted
5650    // admin's rotation adopts a pass late, never early — safe). It is fail-OPEN for
5651    // the base-admissibility protected-set: a superior whose grant this receiver
5652    // has not yet folded is not in `roster.grants`, so a non-owner Refounding
5653    // excluding them can be adopted within that propagation window. Bounded — the
5654    // owner is ALWAYS hard-protected below (independent of the roster) and can
5655    // counter-refound; and it is inherent to eventual consistency (one cannot gate
5656    // on a grant never seen). Tightening this (fold control before the first rekey,
5657    // or gate non-owner adoption on roster freshness) is a follow-on.
5658    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5659    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5660    let me_hex = my_pk.to_hex();
5661    // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
5662    // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
5663    // never honored by a lagging client." Persisted heads ARE the right floor
5664    // here (unlike the roster fold, which must resolve in-pass): a rotation is
5665    // judged against a roster we already folded, and `follow_control` — v2's only
5666    // roster writer — persists the heads in the same pass it writes the roster.
5667    // A joiner who sees a rotation before folding control simply parks it and
5668    // heals on the next follow, which runs control first.
5669    let cited_ok = |rot: &rekey::Rotation| -> bool {
5670        citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5671    };
5672    let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5673        if *rotator == owner {
5674            return true;
5675        }
5676        let rh = rotator.to_hex();
5677        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5678    };
5679    // Concluding MY removal takes more than the bit: the rotator must strictly
5680    // outrank ME (CORD-06 §Authority — "the Rotator must strictly outrank every
5681    // removed target"), so an equal-rank admin can never silently evict a peer
5682    // (or the owner) by minting a complete rotation that skips their blob.
5683    let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5684        if *rotator == owner {
5685            return true;
5686        }
5687        let rh = rotator.to_hex();
5688        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5689    };
5690    // CORD-06 §Authority: a Refounding requires the BAN permission in the folded
5691    // Roster (NOT owner-identity) — any admin holding BAN may perform it, checked
5692    // against the Roster exactly like a channel rekey checks MANAGE_CHANNELS. The
5693    // owner is always authorized. (Owner-only here silently wedged every member
5694    // whose community was refounded by a non-owner admin.)
5695    let base_rotator_ok = |rotator: &PublicKey| -> bool {
5696        if *rotator == owner {
5697            return true;
5698        }
5699        let rh = rotator.to_hex();
5700        !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5701    };
5702    // Concluding MY removal via a base rotation takes more than the bit: the
5703    // rotator must strictly outrank ME with BAN (CORD-06 §Authority — "the
5704    // Rotator must strictly outrank every removed target"), so an equal-rank
5705    // admin can never evict a peer (or the owner) by minting a rotation that
5706    // skips their blob. Adoption (I hold a blob) only needs `base_rotator_ok`.
5707    let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5708        if *rotator == owner {
5709            return true;
5710        }
5711        let rh = rotator.to_hex();
5712        !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5713    };
5714
5715    // Bound the catch-up: each real step consumes a valid authorized rotation, so a
5716    // finite chain terminates naturally; the cap defends against a relay feeding a
5717    // pathological set.
5718    const MAX_STEPS: usize = 128;
5719    for _ in 0..MAX_STEPS {
5720        let mut advanced = false;
5721
5722        // The roots a channel rekey may be addressed under (re-read each pass —
5723        // a base adopt below changes the head, and its predecessor is already
5724        // archived). Shared with streamauth so the auth registration covers
5725        // exactly this fan.
5726        let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5727
5728        // Private channels first: a removal-forced channel rekey rides the PRIOR
5729        // root (CORD-06 D2), so read channels before a base adopt moves it.
5730        let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5731        for cid in channel_ids {
5732            let (held_key, held_epoch) = match cur.channel(&cid) {
5733                Some(ch) => (ch.key, ch.epoch),
5734                None => continue,
5735            };
5736            let next = Epoch(held_epoch.0.saturating_add(1));
5737            let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5738            let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5739            // root #0 = current, #1.. = archived priors (indices only — root
5740            // bytes are key material and must never reach a log).
5741            for (ri, root) in addressing_roots.iter().enumerate() {
5742                let group = channel_rekey_group_key(root, &cid, next);
5743                let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5744                    Ok(c) => c,
5745                    Err(e) => {
5746                        crate::log_warn!(
5747                            "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5748                            &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5749                        );
5750                        return Err(e);
5751                    }
5752                };
5753                if chunks.is_empty() {
5754                    continue;
5755                }
5756                crate::log_debug!(
5757                    "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5758                    &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5759                );
5760                batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5761            }
5762            // Keyless-adopt residual (documented, deferred hardening): a malicious
5763            // AUTHORIZED admin can fork a keyless member onto an orphan low-key
5764            // rotation nothing extends (keyed members' continuity filters it out).
5765            // Recoverable via a fresh bundle; an insider with MANAGE_CHANNELS can
5766            // exclude the member outright anyway, so the marginal harm is the wedge
5767            // outliving their demotion.
5768            match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5769                Advance::Adopt { new_key } => {
5770                    if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5771                        ch.key = Some(new_key);
5772                        ch.epoch = next;
5773                    }
5774                    crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5775                    // The adopter's own multi-epoch archive (the minter archived at
5776                    // mint) — this channel's history stays readable across rotations.
5777                    // fetch_channel compensates for the CURRENT epoch, so a failed
5778                    // archive only bites after the NEXT rotation — surface it.
5779                    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) {
5780                        crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5781                    }
5782                    advanced = true;
5783                    changed = true;
5784                }
5785                Advance::Removed => {
5786                    match held_key {
5787                        // A complete rotation dropped my blob — cut from the channel.
5788                        Some(_) => {
5789                            cur.channels.retain(|c| c.id.0 != cid.0);
5790                        }
5791                        // Keyless scan: this epoch's rotation completed without me.
5792                        // Advance the cursor so the walk converges on the channel's
5793                        // CURRENT epoch — my entry point is its next rotation (whose
5794                        // recipients are the members at that time) or a fresh bundle.
5795                        None => {
5796                            if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5797                                ch.epoch = next;
5798                            }
5799                        }
5800                    }
5801                    advanced = true;
5802                    changed = true;
5803                }
5804                Advance::Stay => {}
5805            }
5806        }
5807
5808        // Base rotation (Refounding): advances the root + root_epoch, re-addressing
5809        // every public channel, the guestbook, and the control plane by derivation
5810        // (refresh_subscription recomputes the author-set from the new root).
5811        {
5812            let held_epoch = cur.root_epoch;
5813            let held_key = cur.community_root;
5814            let next = Epoch(held_epoch.0.saturating_add(1));
5815            let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5816            let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5817            let batches = vec![(chunks, Some((held_epoch, held_key)))];
5818            // A non-owner Refounding may only remove members the rotator strictly
5819            // OUTRANKS. The protected set is the owner plus every grant-holder the
5820            // rotator can't act on with BAN (a peer or superior) — excluding one is
5821            // an authority-escalation takeover, so its rotation is inadmissible.
5822            // Plain members hold no grant and are always outranked by a BAN-holder,
5823            // so removing them is legitimate and needs no memberlist.
5824            let base_admissible = |r: &rekey::Rotation| -> bool {
5825                if r.rotator == owner {
5826                    return true; // the owner is supreme.
5827                }
5828                // Uncited (or citing a Grant we haven't synced) → skip entirely:
5829                // neither adopt nor conclude a removal, exactly like an
5830                // unauthorized rotation. It parks and heals on the next follow.
5831                if !cited_ok(r) {
5832                    return false;
5833                }
5834                let rotator_hex = r.rotator.to_hex();
5835                let has_blob = |xonly: &[u8; 32]| {
5836                    rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5837                };
5838                // The owner is never a valid removed target.
5839                if !has_blob(&owner.to_bytes()) {
5840                    return false;
5841                }
5842                for g in &roster.grants {
5843                    if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5844                        continue; // self, owner (checked), or an already-authorized removal.
5845                    }
5846                    // A grant-holder the rotator can't act on is a peer/superior.
5847                    if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5848                        if let Ok(pk) = PublicKey::from_hex(&g.member) {
5849                            if !has_blob(&pk.to_bytes()) {
5850                                return false; // a peer/superior was excluded.
5851                            }
5852                        }
5853                    }
5854                }
5855                true
5856            };
5857            match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5858                Advance::Adopt { new_key } => {
5859                    cur.community_root = new_key;
5860                    cur.root_epoch = next;
5861                    // Archive on adopt: without this, a member who lived through TWO
5862                    // Refoundings loses the middle epoch's public history (only the
5863                    // minter archived it).
5864                    if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5865                        crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5866                    }
5867                    advanced = true;
5868                    changed = true;
5869                }
5870                Advance::Removed => {
5871                    if !session.is_valid() {
5872                        return Err("account changed during rekey follow".to_string());
5873                    }
5874                    return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5875                }
5876                Advance::Stay => {}
5877            }
5878        }
5879
5880        if !advanced {
5881            break;
5882        }
5883    }
5884
5885    if !changed {
5886        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5887    }
5888    if !session.is_valid() {
5889        return Err("account changed during rekey follow".to_string());
5890    }
5891    // A leave/delete raced this follow: saving would resurrect the community row
5892    // (the save is an upsert) with no floor rows behind it.
5893    if crate::db::community::community_protocol(community.id())?.is_none() {
5894        return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5895    }
5896    crate::db::community::save_community_v2(&cur)?;
5897    // Carry my own live links across the rotation someone ELSE performed
5898    // (CORD-05 §2). The refounder refreshes only the bundles they can reach —
5899    // their own — so without this every other creator's links keep vending the
5900    // superseded root and drop new joiners onto a dead epoch, which is exactly
5901    // the stranding the stable-URL refresh exists to prevent. Best-effort and
5902    // idempotent: a creator with no links for this community returns early, and
5903    // a failure only delays the heal until the next adoption or refound.
5904    let _ = refresh_public_links(transport, &cur).await;
5905    Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5906}
5907
5908/// One scope's catch-up decision from the rekey chunks fetched at its next-epoch
5909/// address.
5910enum Advance {
5911    /// Adopt this fresh key for `next_epoch`.
5912    Adopt { new_key: [u8; 32] },
5913    /// A complete owner rotation at `next_epoch` dropped my blob — I'm removed.
5914    Removed,
5915    /// No owner rotation extends my held epoch (yet) — keep the current key.
5916    Stay,
5917}
5918
5919/// Fetch + parse every seal-verified 3303 chunk at a rekey plane address.
5920async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5921    transport: &T,
5922    relays: &[String],
5923    group: &GroupKey,
5924) -> Result<Vec<rekey::RekeyChunk>, String> {
5925    // A rekey plane address is community_root-derived, so ANY member can seal junk
5926    // 3303s there — a flood (or, organically, a large community's own multi-chunk
5927    // rotation past the newest window) could bury the genuine owner/admin rotation
5928    // in a single fixed page. PAGE backwards (inclusive until + wrap-id dedup, the
5929    // control pager's discipline) so a buried authorized chunk is still recovered;
5930    // the seal + authority filter downstream drops the junk. Bounded — a sustained
5931    // flood past this depth degrades to "adopt one pass late", never a false state.
5932    const REKEY_PAGE: usize = 200;
5933    const REKEY_MAX_PAGES: usize = 6;
5934    let mut out = Vec::new();
5935    let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5936    let mut until: Option<u64> = None;
5937    let mut oldest: Option<u64> = None;
5938    for _ in 0..REKEY_MAX_PAGES {
5939        let query = Query {
5940            kinds: vec![stream::KIND_WRAP],
5941            authors: vec![group.pk_hex()],
5942            until,
5943            limit: Some(REKEY_PAGE),
5944            ..Default::default()
5945        };
5946        // Authenticate AS the rekey plane key: on AUTH-gating relays (Ditto) the
5947        // shared user-authed client's REQ for a plane's events is CLOSED, so an
5948        // offline rotation catch-up would return nothing and wedge at the old
5949        // epoch. `fetch_plane` rides a connection authed as the plane itself.
5950        let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5951        let mut fresh = 0usize;
5952        for w in &wraps {
5953            if !seen.insert(w.id) {
5954                continue;
5955            }
5956            fresh += 1;
5957            let at = w.created_at.as_secs();
5958            if oldest.is_none_or(|o| at < o) {
5959                oldest = Some(at);
5960            }
5961            if let Ok(opened) = stream::open_wrap(w, group) {
5962                if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5963                    out.push(chunk);
5964                }
5965            }
5966        }
5967        // Drained, or a same-second wall the pager can't step past (second-granular
5968        // until) — either way stop; the accumulated set is what advance_scope folds.
5969        if fresh == 0 || wraps.len() < REKEY_PAGE {
5970            break;
5971        }
5972        match oldest {
5973            Some(o) if o > 0 => until = Some(o),
5974            _ => break,
5975        }
5976    }
5977    Ok(out)
5978}
5979
5980/// Decide how a scope advances from per-addressing-root chunk batches (pure). Each
5981/// batch pairs the chunks fetched under one root with the continuity to demand of
5982/// them: a rotation qualifies when it's rotator-authorized (`rotator_ok`),
5983/// complete, targets the immediate `next_epoch`, and — when I hold a chain —
5984/// extends my exact `(epoch, key)`. A KEYLESS batch (`held` = None) has no chain
5985/// to extend, so it qualifies on authority + completeness alone (CORD-06 §2:
5986/// continuity is "a convergence check, not a secrecy mechanism"; the rotator's
5987/// seal authority is the boundary). Among qualifying rotations carrying my blob
5988/// the lexicographically lowest new key wins (convergent). All complete
5989/// candidates without my blob conclude Removed for a KEYED holder only when one
5990/// came from a rotator who may remove ME (`rotator_may_remove_me`, the CORD-06
5991/// strict-outrank rule) — else Stay; for a keyless holder they merely advance the
5992/// scan cursor (any bit-holder's real rotation is scan progress, never a loss).
5993async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5994    batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5995    scope: RekeyScope,
5996    rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5997    rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5998    admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5999    signer: &S,
6000    my_xonly: &[u8; 32],
6001    next_epoch: Epoch,
6002) -> Advance {
6003    let mut winners: Vec<[u8; 32]> = Vec::new();
6004    let mut saw_complete_candidate = false;
6005    let mut saw_outranking_candidate = false;
6006    let keyed = batches.iter().any(|(_, held)| held.is_some());
6007    for (chunks, held) in batches {
6008        let rotations = rekey::collect_rotations(chunks);
6009        for r in &rotations {
6010            if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
6011                continue;
6012            }
6013            if let Some((held_epoch, held_key)) = held {
6014                if r.continuity(*held_epoch, held_key) != Continuity::Extends {
6015                    continue;
6016                }
6017            }
6018            // CORD-06 §Authority: a rotator must strictly OUTRANK every removed
6019            // target. An authorized-but-inadmissible rotation (one that excludes
6020            // the owner or a peer/superior the rotator can't act on) is a takeover
6021            // attempt — skip it entirely, so it neither adopts nor concludes a
6022            // removal (it forks; the honest chain wins).
6023            if !admissible(r) {
6024                continue;
6025            }
6026            saw_complete_candidate = true;
6027            saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
6028            if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
6029                if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
6030                    winners.push(k);
6031                }
6032            }
6033        }
6034    }
6035    if !winners.is_empty() {
6036        // `collect_rotations` correlates on `(rotator, scope, new_epoch, prev_commit)`,
6037        // so a single rotator's blobs merge into ONE rotation (and a retried Refounding
6038        // MINT-OR-REUSES its root, so it never emits two distinct roots to fork on).
6039        // The lowest-key tiebreak engages only for CONCURRENT DISTINCT rotators racing
6040        // the same epoch (separate rotations): every follower converges on the same
6041        // lowest new key. A wrap served under two addressing roots can't double-count:
6042        // each rekey wrap opens under exactly one root's group key.
6043        let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
6044        return Advance::Adopt { new_key: winners[idx] };
6045    }
6046    if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
6047        Advance::Removed
6048    } else {
6049        Advance::Stay
6050    }
6051}
6052
6053// ── Pins (CORD-04 §7) ────────────────────────────────────────────────────────
6054
6055/// A channel's pin list, read from the locally folded head.
6056#[derive(Debug, serde::Serialize)]
6057pub struct ChannelPins {
6058    /// Entries that passed the full §7 verification, wire order (curator's).
6059    pub pins: Vec<super::pins::VerifiedPin>,
6060    /// The head is sealed under a key epoch this client does not hold: the
6061    /// pins exist but are unreadable. Render as unavailable, NEVER as empty —
6062    /// and a writer seeing this MUST NOT publish (it would drop every entry).
6063    pub sealed: bool,
6064    /// Folded head version (0 = no edition has ever folded).
6065    pub version: u64,
6066}
6067
6068/// The channel's stream conversation key at `epoch`, if this client holds the
6069/// deriving secret: a private channel's held per-epoch key, a public channel's
6070/// held base root at that epoch.
6071fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
6072    let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
6073    // A private plane is never derived from the root value (that would address
6074    // the public plane) — mirrors fetch_channel_history's invariant.
6075    if ch.private && ikm == community.community_root {
6076        return None;
6077    }
6078    let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
6079    group.conv_key().as_bytes().try_into().ok()
6080}
6081
6082/// Read a channel's pins from the locally folded head: unseal (private form),
6083/// verify every entry, keep wire order. Local-only — the control follow is what
6084/// moves the head.
6085pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
6086    let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
6087    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6088    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6089    let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
6090        return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
6091    };
6092    let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
6093    let pins = read
6094        .entries
6095        .iter()
6096        .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
6097        .collect();
6098    Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
6099}
6100
6101/// Publish `entries` as the channel's next Pin List edition, in the form the
6102/// channel's folded type mandates, and echo it locally so a follow-up edit
6103/// builds on this write rather than the pre-write fold.
6104async fn publish_pin_list<T: Transport + ?Sized>(
6105    transport: &T,
6106    community: &CommunityV2,
6107    session: &SessionGuard,
6108    ch: &ChannelV2,
6109    entries: &[super::pins::PinEntry],
6110) -> Result<(), String> {
6111    let content = if ch.private {
6112        let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
6113        let group = channel_group_key(&key, &ch.id, ch.epoch);
6114        super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
6115    } else {
6116        super::pins::serialize_public_pin_list(entries)?
6117    };
6118    let eid = super::derive::pins_locator(community.id(), &ch.id);
6119    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6120    let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6121    let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
6122    // The version this publish will chain to — mirrors publish_control_edition.
6123    let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
6124        Some((v, _)) => v + 1,
6125        None => 1,
6126    };
6127    crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
6128    publish_control_edition(transport, community, session, vsk::PINS, &eid, &content).await?;
6129    if session.is_valid() {
6130        let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
6131        crate::emit_event(
6132            "community_pins_updated",
6133            &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
6134        );
6135        if let Ok(me) = me_pk() {
6136            use nostr_sdk::prelude::ToBech32;
6137            let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
6138            note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
6139        }
6140    }
6141    Ok(())
6142}
6143
6144/// One centered system row per adopted Pin List edition — "X modified the
6145/// Pins". The id is deterministic on (channel, version), so the publisher's
6146/// echo and every fold that adopts the same edition collapse into one row,
6147/// and a catch-up fold stamps the edition's own time so history sorts true.
6148async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
6149    let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
6150    let inserted = crate::db::events::save_system_event_at(
6151        &event_id,
6152        channel_hex,
6153        crate::stored_event::SystemEventType::PinsModified,
6154        actor_npub,
6155        None,
6156        at_secs,
6157        None,
6158        None,
6159    )
6160    .await
6161    .unwrap_or(false);
6162    if inserted {
6163        crate::emit_event(
6164            "system_event",
6165            &serde_json::json!({
6166                "conversation_id": channel_hex,
6167                "event_id": event_id,
6168                "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
6169                "member_pubkey": actor_npub,
6170            }),
6171        );
6172    }
6173}
6174
6175/// The current entries this writer may build on. Replace-entire cuts sharply
6176/// (§7): an empty view has two innocent causes indistinguishable from an empty
6177/// list, so a writer MUST refuse to build from a list it could not read.
6178fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
6179    let current = read_channel_pins(community, channel_id)?;
6180    if current.sealed {
6181        return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
6182    }
6183    Ok(current.pins.into_iter().map(|p| p.entry).collect())
6184}
6185
6186/// Pin a message: recover its wrap, rebuild its proof, append, republish.
6187///
6188/// The seal is re-fetched from the community relays by the stored wrapper id —
6189/// the DB retains rumors, not seals, and a proof needs the seal verbatim.
6190pub async fn pin_message<T: Transport + ?Sized>(
6191    transport: &T,
6192    community: &CommunityV2,
6193    channel_id: &ChannelId,
6194    rumor_id_hex: &str,
6195) -> Result<(), String> {
6196    let session = SessionGuard::capture();
6197    let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6198    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6199
6200    let mut entries = writable_pin_entries(community, channel_id)?;
6201    if entries.len() >= super::pins::PIN_MAX_ENTRIES {
6202        return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
6203    }
6204    // Idempotent: re-pinning an already-pinned message is a no-op, not an error.
6205    if entries
6206        .iter()
6207        .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
6208        .any(|v| v.rumor_id == rumor_id_hex)
6209    {
6210        return Ok(());
6211    }
6212
6213    let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
6214        .ok_or("message not found in this device's history")?;
6215    let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
6216
6217    // Recover the wrap verbatim — Full evidence: a pin is a permanent artifact,
6218    // so don't build it from the first relay to answer.
6219    let wraps = transport
6220        .fetch(
6221            &Query {
6222                ids: vec![wrap_id.clone()],
6223                kinds: vec![super::stream::KIND_WRAP],
6224                limit: Some(1),
6225                evidence: crate::community::transport::Evidence::Full,
6226                ..Default::default()
6227            },
6228            &community.relays,
6229        )
6230        .await?;
6231    let wrap = wraps
6232        .iter()
6233        .find(|w| w.id.to_hex() == wrap_id)
6234        .ok_or("the message's wrap is no longer served by this community's relays")?;
6235
6236    // The stored row does not retain the rumor's epoch binding, so re-derive it
6237    // the way history reads do: try the channel's every held plane coordinate,
6238    // current epoch first, until the wrap opens AND carries this rumor. The
6239    // open itself verifies the channel + epoch binding, so a false coordinate
6240    // fails closed rather than mis-attributing.
6241    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6242    let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
6243    if ch.private {
6244        if let Some(k) = ch.key {
6245            coords.push((ch.epoch.0, k));
6246        }
6247        let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
6248        coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
6249    } else {
6250        coords.push((community.root_epoch.0, community.community_root));
6251        let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
6252        coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
6253    }
6254    coords.dedup();
6255
6256    let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
6257    for (epoch, ikm) in coords {
6258        let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
6259        if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
6260            super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
6261        {
6262            if opened.rumor_id.to_hex() == rumor_id_hex {
6263                let conv: [u8; 32] = group
6264                    .conv_key()
6265                    .as_bytes()
6266                    .try_into()
6267                    .map_err(|_| "conversation key size".to_string())?;
6268                found = Some((opened, conv));
6269                break;
6270            }
6271        }
6272    }
6273    let Some((opened, conv_key)) = found else {
6274        return Err("that message is from a key epoch this device no longer holds".to_string());
6275    };
6276
6277    let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
6278        super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
6279        super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
6280        super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
6281    })?;
6282    entries.push(entry);
6283
6284    if !session.is_valid() {
6285        return Err("account changed before the pin was published".to_string());
6286    }
6287    publish_pin_list(transport, community, &session, &ch, &entries).await
6288}
6289
6290/// The deriving secret (ikm) for a channel plane at `epoch` — the same lookup
6291/// `channel_conv_key_at` performs, surfaced for the open path.
6292fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
6293    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6294    if ch.private {
6295        if ch.epoch.0 == epoch {
6296            return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
6297        }
6298        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6299        crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
6300            .unwrap_or_default()
6301            .into_iter()
6302            .find(|(ep, _)| ep.0 == epoch)
6303            .map(|(_, k)| k)
6304            .ok_or("that message is from a key epoch this device no longer holds".to_string())
6305    } else if community.root_epoch.0 == epoch {
6306        Ok(community.community_root)
6307    } else {
6308        crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
6309            .unwrap_or_default()
6310            .into_iter()
6311            .find(|(ep, _)| ep.0 == epoch)
6312            .map(|(_, k)| k)
6313            .ok_or("that message is from a root epoch this device no longer holds".to_string())
6314    }
6315}
6316
6317/// Unpin a message: the next edition without the entry (§7 — no deletion event).
6318pub async fn unpin_message<T: Transport + ?Sized>(
6319    transport: &T,
6320    community: &CommunityV2,
6321    channel_id: &ChannelId,
6322    rumor_id_hex: &str,
6323) -> Result<(), String> {
6324    let session = SessionGuard::capture();
6325    let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6326    let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6327    let entries = writable_pin_entries(community, channel_id)?;
6328    let kept: Vec<super::pins::PinEntry> = entries
6329        .into_iter()
6330        .filter(|e| {
6331            super::pins::verify_pin_entry(e, &ch_hex)
6332                .map(|v| v.rumor_id != rumor_id_hex)
6333                // An entry we can't verify is kept: unpin removes exactly the
6334                // named message, never collateral.
6335                .unwrap_or(true)
6336        })
6337        .collect();
6338    publish_pin_list(transport, community, &session, &ch, &kept).await
6339}
6340
6341/// §7 curator duties: converge the Pin List when a pinned message is deleted
6342/// or edited. Spawned fire-and-forget from ingest — a non-curator, a sealed
6343/// list, or an unpinned target all no-op silently; the duty is voluntary.
6344pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
6345    let channel_hex = channel_hex.to_string();
6346    let target = target_rumor_hex.to_string();
6347    let session = SessionGuard::capture();
6348    tokio::spawn(async move {
6349        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
6350        let _ = run_pin_duty(&transport, &channel_hex, &target, edit, session).await;
6351    });
6352}
6353
6354/// The duty body, transport-injected so tests can drive it end to end.
6355///
6356/// The affected author acts at once; every other PIN_MESSAGES holder waits a
6357/// deterministic 5-25s stagger (hashed from (me, target) — no thundering herd
6358/// of racing editions) and re-reads before publishing, so a duty another
6359/// curator already performed dissolves into a no-op.
6360async fn run_pin_duty<T: Transport + ?Sized>(
6361    transport: &T,
6362    channel_hex: &str,
6363    target: &str,
6364    edit: Option<super::stream::OpenedStream>,
6365    session: SessionGuard,
6366) -> Result<(), String> {
6367    use crate::community::roles::Permissions;
6368    let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
6369        return Ok(());
6370    };
6371    let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
6372    let Some(community) = crate::db::community::load_community_v2(&cid)? else {
6373        return Ok(());
6374    };
6375    let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
6376
6377    // Cheap pre-checks before any waiting: pinned target, readable list, held bit.
6378    let read = read_channel_pins(&community, &channel_id)?;
6379    if read.sealed {
6380        return Ok(());
6381    }
6382    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6383        return Ok(());
6384    };
6385    if let Some(ed) = &edit {
6386        // Monotonic: a bundle at or past this revision needs no refresh.
6387        if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6388            return Ok(());
6389        }
6390    }
6391    let me = me_pk()?;
6392    let me_hex = me.to_hex();
6393    let owner_hex = community.owner()?.to_hex();
6394    let roster = crate::db::community::get_community_roles(&cid_hex)?;
6395    if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
6396        return Ok(());
6397    }
6398
6399    if hit.author != me_hex {
6400        let mut h: u32 = 0;
6401        for b in me_hex.bytes().chain(target.bytes()) {
6402            h = h.wrapping_mul(31).wrapping_add(u32::from(b));
6403        }
6404        tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
6405        if !session.is_valid() {
6406            return Ok(());
6407        }
6408    }
6409
6410    // Re-read after the stagger: another curator's edition may have landed.
6411    let read = read_channel_pins(&community, &channel_id)?;
6412    if read.sealed {
6413        return Ok(());
6414    }
6415    let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6416        return Ok(());
6417    };
6418    let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
6419
6420    let entries: Vec<super::pins::PinEntry> = match &edit {
6421        // Deletion: the next edition simply omits the entry (§7 — replace-entire).
6422        None => read
6423            .pins
6424            .iter()
6425            .filter(|p| p.rumor_id != target)
6426            .map(|p| p.entry.clone())
6427            .collect(),
6428        // Edit: the same entry, its bundle refreshed to the newest revision.
6429        Some(ed) => {
6430            if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6431                return Ok(());
6432            }
6433            let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6434            // The edit sealed under the channel's current plane in the common
6435            // (realtime) case; older epochs are tried like every other read.
6436            let mut bundle = None;
6437            let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
6438            let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
6439            epochs.extend(
6440                crate::db::community::held_epoch_keys(&cid_hex, &scope)
6441                    .unwrap_or_default()
6442                    .into_iter()
6443                    .map(|(ep, _)| ep.0),
6444            );
6445            epochs.dedup();
6446            for epoch in epochs {
6447                let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
6448                if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
6449                    bundle = Some(b);
6450                    break;
6451                }
6452            }
6453            let Some(bundle) = bundle else { return Ok(()) };
6454            read.pins
6455                .iter()
6456                .map(|p| {
6457                    let mut entry = p.entry.clone();
6458                    if p.rumor_id == target {
6459                        entry.edit = Some(bundle.clone());
6460                    }
6461                    entry
6462                })
6463                .collect()
6464        }
6465    };
6466
6467    if !session.is_valid() {
6468        return Ok(());
6469    }
6470    crate::log_info!(
6471        "[pins] duty {} for target {} in channel {}",
6472        if edit.is_some() { "edit-refresh" } else { "omission" },
6473        &target[..12],
6474        &channel_hex[..12]
6475    );
6476    publish_pin_list(transport, &community, &session, &ch, &entries).await
6477}
6478
6479/// Silent owner-side widening: an Admin role published before PIN_MESSAGES
6480/// existed gains the bit, so delegated admins can curate pins in communities
6481/// founded before this build. One edition, idempotent, converges across owner
6482/// devices (both publish the same widened mask as editions of one entity).
6483pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
6484    transport: &T,
6485    community: &CommunityV2,
6486) -> Result<bool, String> {
6487    use crate::community::roles::{Permissions, RoleScope};
6488    let my_pk = me_pk()?;
6489    if community.owner()? != my_pk {
6490        return Ok(false);
6491    }
6492    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6493    let roles = crate::db::community::get_community_roles(&cid_hex)?;
6494    let Some(role) = roles.roles.iter().find(|r| {
6495        matches!(r.scope, RoleScope::Server)
6496            && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
6497            && !r.permissions.contains(Permissions::PIN_MESSAGES)
6498    }) else {
6499        return Ok(false);
6500    };
6501    let mut widened = role.clone();
6502    widened.permissions.0 |= Permissions::PIN_MESSAGES;
6503    set_role(transport, community, &widened).await?;
6504    Ok(true)
6505}
6506
6507#[cfg(test)]
6508mod tests {
6509    use crate::ClientRelayExt;
6510    use nostr_sdk::prelude::FinalizeEvent;
6511    use super::super::super::transport::memory::MemoryRelay;
6512    use super::*;
6513    use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
6514
6515    /// A distinct npub-shaped account-dir name (bech32 charset) per counter.
6516    fn account_name(n: u32) -> String {
6517        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
6518        let mut acct = String::from("npub1");
6519        let mut v = n as usize;
6520        for _ in 0..58 {
6521            acct.push(B[v % 32] as char);
6522            v = v / 32 + 7;
6523        }
6524        acct
6525    }
6526
6527    /// One test participant: its identity keys and its isolated account DB dir.
6528    struct Actor {
6529        keys: Keys,
6530        account: String,
6531    }
6532
6533    /// Two participants sharing one relay but isolated per-account DBs — the
6534    /// cross-account harness a real invite/join loop needs. `swap_to` mirrors a
6535    /// live `swap_session`: re-point the DB pool + rebind the identity + clear
6536    /// the per-account id caches, so account A's community is invisible to B
6537    /// until B legitimately joins.
6538    struct TestBed {
6539        _tmp: tempfile::TempDir,
6540        _guard: std::sync::MutexGuard<'static, ()>,
6541        relay: MemoryRelay,
6542        relays: Vec<String>,
6543    }
6544
6545    impl TestBed {
6546        fn new() -> (TestBed, Actor, Actor) {
6547            static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
6548            let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6549            crate::db::close_database();
6550            crate::db::clear_id_caches();
6551            let tmp = tempfile::tempdir().unwrap();
6552            crate::db::set_app_data_dir(tmp.path().to_path_buf());
6553
6554            let mk = || {
6555                let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6556                let account = account_name(n);
6557                std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
6558                crate::db::set_current_account(account.clone()).unwrap();
6559                crate::db::init_database(&account).unwrap();
6560                Actor { keys: Keys::generate(), account }
6561            };
6562            let owner = mk();
6563            let member = mk();
6564            let _ = crate::state::take_nostr_client();
6565            let bed = TestBed {
6566                _tmp: tmp,
6567                _guard: guard,
6568                relay: MemoryRelay::new(),
6569                relays: vec!["wss://r".to_string()],
6570            };
6571            (bed, owner, member)
6572        }
6573
6574        /// Become `actor`: swap the account DB + identity, as a real session swap.
6575        /// Bumps the session generation like production `swap_session` does — so any task a
6576        /// prior actor spawned (e.g. the migration finalize) dies at its SessionGuard check
6577        /// instead of racing this actor's DB (a cross-test flake that can't happen in prod).
6578        fn swap_to(&self, actor: &Actor) {
6579            crate::state::bump_session_generation();
6580            crate::db::set_current_account(actor.account.clone()).unwrap();
6581            crate::db::init_database(&actor.account).unwrap();
6582            crate::db::clear_id_caches();
6583            crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
6584            crate::state::set_my_public_key(actor.keys.public_key());
6585        }
6586    }
6587
6588    /// Legacy single-actor helper (the create/send tests below).
6589    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
6590        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6591        crate::db::close_database();
6592        crate::db::clear_id_caches();
6593        let tmp = tempfile::tempdir().unwrap();
6594        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
6595        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6596        let acct = account_name(n);
6597        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
6598        crate::db::set_app_data_dir(tmp.path().to_path_buf());
6599        crate::db::set_current_account(acct.clone()).unwrap();
6600        crate::db::init_database(&acct).unwrap();
6601        let _ = crate::state::take_nostr_client();
6602        let owner = Keys::generate();
6603        crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
6604        crate::state::set_my_public_key(owner.public_key());
6605        (tmp, guard, owner)
6606    }
6607
6608    /// A transport that simulates a session swap landing DURING a fetch await —
6609    /// so a join straddling the fetch sees an invalid session and aborts.
6610    struct SwapMidFetch {
6611        inner: MemoryRelay,
6612    }
6613    #[async_trait::async_trait]
6614    impl Transport for SwapMidFetch {
6615        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6616        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6617            self.inner.publish(e, r).await
6618        }
6619        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6620            self.inner.publish_durable(e, r).await
6621        }
6622        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6623            let out = self.inner.fetch(q, r).await;
6624            crate::state::bump_session_generation();
6625            out
6626        }
6627    }
6628
6629    /// Bumps the session generation on the first `publish_durable` — the rekey
6630    /// crate a private-channel create ships before it writes anything locally.
6631    struct SwapMidPublish {
6632        inner: MemoryRelay,
6633    }
6634    #[async_trait::async_trait]
6635    impl Transport for SwapMidPublish {
6636        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6637        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6638            self.inner.publish(e, r).await
6639        }
6640        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6641            let out = self.inner.publish_durable(e, r).await;
6642            crate::state::bump_session_generation();
6643            out
6644        }
6645        async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6646            self.inner.fetch(q, r).await
6647        }
6648    }
6649
6650    /// A transport whose `fetch` returns a FIXED, UNSORTED event list — modelling
6651    /// the production `LiveTransport` union (first-responding relay's batch, no
6652    /// global newest-first sort), which `MemoryRelay` hides by sorting. This is
6653    /// the only harness that can exercise the revocation-race ordering.
6654    struct FixedFetch {
6655        events: Vec<Event>,
6656    }
6657    #[async_trait::async_trait]
6658    impl Transport for FixedFetch {
6659        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6660        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6661            Ok(())
6662        }
6663        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6664            Ok(())
6665        }
6666        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6667            Ok(self.events.clone())
6668        }
6669    }
6670
6671    /// Fetch a pending Direct Invite (kind 3313 giftwrap) addressed to `me` — the
6672    /// indexed inbox query CORD-05 §6 defines: `{1059, #p:[me], #k:["3313"]}`.
6673    async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
6674        let q = Query {
6675            kinds: vec![stream::KIND_WRAP],
6676            p_tags: vec![me.to_hex()],
6677            k_tags: vec!["3313".to_string()],
6678            ..Default::default()
6679        };
6680        relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
6681    }
6682
6683    #[tokio::test]
6684    async fn create_persists_and_reloads_a_v2_community() {
6685        let (_tmp, _guard, owner) = init_test_db();
6686        let relay = MemoryRelay::new();
6687        let relays = vec!["wss://r".to_string()];
6688
6689        let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
6690        assert!(created.identity.verify());
6691        assert_eq!(created.owner().unwrap(), owner.public_key());
6692        assert_eq!(created.channels.len(), 1);
6693
6694        // Protocol dispatch sees it as v2, and it reloads byte-faithfully.
6695        assert_eq!(
6696            crate::db::community::community_protocol(created.id()).unwrap(),
6697            Some(crate::community::ConcordProtocol::V2)
6698        );
6699        let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
6700        assert_eq!(loaded.name, "Vectorville");
6701        assert_eq!(loaded.community_root, created.community_root);
6702        assert_eq!(loaded.identity, created.identity);
6703        assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
6704        assert!(!loaded.channels[0].private);
6705
6706        // The genesis control editions + the owner Join landed on the relay.
6707        assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
6708    }
6709
6710    #[tokio::test]
6711    async fn owner_sends_and_reads_back_a_message() {
6712        let (_tmp, _guard, _owner) = init_test_db();
6713        let relay = MemoryRelay::new();
6714        let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
6715        let general = community.channels[0].id;
6716
6717        let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
6718        let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
6719        assert_ne!(id1, id2);
6720
6721        let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
6722        let texts: Vec<String> = page
6723            .iter()
6724            .filter_map(|f| match &f.event {
6725                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6726                _ => None,
6727            })
6728            .collect();
6729        assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6730    }
6731
6732    #[tokio::test]
6733    async fn a_second_member_reads_the_public_channel_from_the_root() {
6734        // A member who holds the community_root (via an invite bundle, modeled
6735        // here by cloning the community) reads the owner's public-channel message
6736        // — public channels need no key delivery, they derive from the root.
6737        let (_tmp, _guard, _owner) = init_test_db();
6738        let relay = MemoryRelay::new();
6739        let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6740        let general = community.channels[0].id;
6741        send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6742
6743        // The "member" reconstructs the same read coordinates from the root.
6744        let member_view = community.clone();
6745        let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6746        assert_eq!(page.len(), 1);
6747        assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6748        assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6749    }
6750
6751    // ── Two-actor end-to-end (the create → invite → join → message loop) ──────
6752
6753    async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6754        fetch_channel(relay, community, channel, 100)
6755            .await
6756            .unwrap()
6757            .iter()
6758            .filter_map(|f| match &f.event {
6759                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6760                _ => None,
6761            })
6762            .collect()
6763    }
6764
6765    #[tokio::test]
6766    async fn direct_invite_full_loop_owner_and_member_converse() {
6767        let (bed, owner, member) = TestBed::new();
6768
6769        // Owner creates a community, posts, and Direct-Invites the member's npub.
6770        bed.swap_to(&owner);
6771        let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6772        let general = community.channels[0].id;
6773        send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6774        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6775
6776        // Member (a DIFFERENT account, no prior knowledge) finds + accepts the invite.
6777        bed.swap_to(&member);
6778        assert!(
6779            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6780            "the member does not hold the community before joining"
6781        );
6782        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6783        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6784        assert_eq!(joined.id().0, community.id().0, "joined the same community");
6785        assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6786        assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6787
6788        // The member reads the owner's public-channel history and replies.
6789        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6790        send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6791
6792        // The owner reads the member's reply.
6793        bed.swap_to(&owner);
6794        assert_eq!(
6795            texts_in(&bed.relay, &community, &general).await,
6796            vec!["owner: welcome!", "member: thanks for the invite"],
6797            "both actors' messages interleave in ms order on the shared channel"
6798        );
6799
6800        // The Guestbook memberlist now folds both participants.
6801        let members = memberlist(&bed.relay, &community).await.unwrap();
6802        assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6803        assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6804        assert_eq!(members.len(), 2);
6805    }
6806
6807    /// Join-time ban gate: an honest client whose npub is on the authorized banlist
6808    /// refuses to join — no Guestbook Join publish, no local write — through the shared
6809    /// accept path every door (direct invite, parked, public link, migration) funnels into.
6810    #[tokio::test]
6811    async fn a_banned_member_is_refused_at_join_time() {
6812        let (bed, owner, member) = TestBed::new();
6813
6814        bed.swap_to(&owner);
6815        let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6816        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6817        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6818
6819        bed.swap_to(&member);
6820        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6821        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6822        assert!(err.contains("banned"), "refusal names the reason: {err}");
6823        assert!(
6824            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6825            "a refused join persists nothing"
6826        );
6827
6828        // The gate is the LAST word only for banned members: an unbanned bystander with
6829        // the same invite path still joins (the gate doesn't over-refuse).
6830        bed.swap_to(&owner);
6831        set_banlist(&bed.relay, &community, &[]).await.unwrap();
6832        bed.swap_to(&member);
6833        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6834        assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6835    }
6836
6837    /// End-to-end member migration: a member holding a v1 community folds the owner's
6838    /// migration dissolution, opens `m`, joins the v2 twin (ban-gated), and the flip
6839    /// re-parents the stitched channel rows + stamps the fence — all from the single event.
6840    #[tokio::test]
6841    async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6842        use crate::community::migration;
6843        let (bed, owner, member) = TestBed::new();
6844
6845        // Owner builds the v2 twin (real, verifiable on the shared relay).
6846        bed.swap_to(&owner);
6847        let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6848        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6849        let jm = join_material(&v2);
6850
6851        // The member holds a v1 community owned by the SAME owner identity (the migration
6852        // premise) — construct + save it, and hold its server root.
6853        bed.swap_to(&member);
6854        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6855        let v1_cid = v1.id.to_hex();
6856        v1.owner_attestation = Some({
6857            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6858                .finalize(&owner.keys).unwrap().as_json()
6859        });
6860        crate::db::community::save_community(&v1).unwrap();
6861        let v1_channel = v1.channels[0].id.to_hex();
6862
6863        // The dissolution payload: v2 JoinMaterial sealed under the v1 server root.
6864        let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6865        let signpost = migration::MigrationSignpost {
6866            v2_community_id: v2_hex.clone(),
6867            owner_xonly: owner.keys.public_key().to_hex(),
6868            owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6869            relays: bed.relays.clone(),
6870            name: "Guild".into(),
6871            primary_channel: v1_channel.clone(),
6872            root_epoch: 0,
6873        };
6874        let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6875        crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6876
6877        // Drive the migration: opens m, joins v2 (ban-gated), flips.
6878        let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6879        assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6880
6881        // Fence: the v1 community is terminally marked, and the v2 twin is held + joined.
6882        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6883        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6884        assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6885        let _ = v1_channel;
6886
6887        // Idempotent: a second drive is a no-op (already flipped).
6888        assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6889    }
6890
6891    /// The OWNER wizard end-to-end: build the twin (primary channel reuses the v1 id),
6892    /// seal + publish the carrier, flip the owner. Then a MEMBER holding the v1 community
6893    /// folds the same carrier and stitches — proving the channel-STITCH the earlier test
6894    /// couldn't (that twin had mismatched ids).
6895    #[tokio::test]
6896    async fn owner_wizard_then_member_migrate_and_stitch() {
6897        use crate::community::migration;
6898        let (bed, owner, member) = TestBed::new();
6899        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6900
6901        // Owner holds a v1 community (they created it) with one channel.
6902        bed.swap_to(&owner);
6903        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6904        let v1_cid = v1.id.to_hex();
6905        let v1_channel = v1.channels[0].id.to_hex();
6906        v1.owner_attestation = Some({
6907            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6908                .finalize(&owner.keys).unwrap().as_json()
6909        });
6910        crate::db::community::save_community(&v1).unwrap();
6911
6912        // Run the wizard.
6913        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6914        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6915            "owner's own client flipped to v2");
6916        // The owner's v1 channel row re-parented to the twin (stitch), because the twin's
6917        // primary channel REUSES the v1 channel id.
6918        assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6919            "owner channel stitched to v2");
6920
6921        // A MEMBER holding the same v1 community folds the carrier and migrates.
6922        bed.swap_to(&member);
6923        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6924        // The member's v1 community must be the SAME id + root the owner published under.
6925        m_v1.id = v1.id;
6926        m_v1.server_root_key = v1.server_root_key.clone();
6927        m_v1.channels[0].id = v1.channels[0].id;
6928        m_v1.owner_attestation = v1.owner_attestation.clone();
6929        crate::db::community::save_community(&m_v1).unwrap();
6930
6931        // Fold the carrier off the relay: the dissolution arm seals, persists the pointer,
6932        // AND auto-drives the flip — the live one-event member experience, no manual step.
6933        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6934        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6935        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6936            "the FOLD ITSELF flipped the member (auto-drive)");
6937        assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6938            "member holds the v2 twin");
6939        // A manual re-drive is an idempotent no-op.
6940        assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6941    }
6942
6943    /// The wizard records the twin in the cross-device community list, like every other v2
6944    /// join/create path. Sibling devices normally discover the twin by folding the carrier
6945    /// themselves, but one that no longer holds the v1 community has no carrier to fold, so
6946    /// the list is its only route in.
6947    #[tokio::test]
6948    async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6949        use crate::community::migration;
6950        let (bed, owner, _member) = TestBed::new();
6951        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6952
6953        bed.swap_to(&owner);
6954        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6955        let v1_cid = v1.id.to_hex();
6956        v1.owner_attestation = Some({
6957            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6958                .finalize(&owner.keys).unwrap().as_json()
6959        });
6960        crate::db::community::save_community(&v1).unwrap();
6961
6962        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6963
6964        // The twin is live in the published list, so a fresh/carrier-less device finds it.
6965        let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6966            .expect("the wizard published a community list");
6967        assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6968        // The v1 community is NOT tombstoned there: a tombstone reads as "you left" and
6969        // `sync_community_list` would tear down a sibling's v1 row before it can fold the
6970        // carrier, stranding it. The local `migrated_to` fence is what stops v1 ghosts.
6971        assert!(
6972            !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6973            "migration must not tombstone the v1 community"
6974        );
6975    }
6976
6977    /// The wizard takes the same per-cid claim the member drive does, so a double-fired
6978    /// command (or the owner's own carrier self-fold racing the wizard's phase 2→3 gap)
6979    /// cannot run two wizards: the second would re-mint a twin before the ledger lands
6980    /// (the double-mint orphan) and race its flip against the first.
6981    #[tokio::test]
6982    async fn wizard_refuses_while_a_drive_holds_the_claim() {
6983        use crate::community::migration;
6984        let (bed, owner, _member) = TestBed::new();
6985        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6986
6987        bed.swap_to(&owner);
6988        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6989        let v1_cid = v1.id.to_hex();
6990        v1.owner_attestation = Some({
6991            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6992                .finalize(&owner.keys).unwrap().as_json()
6993        });
6994        crate::db::community::save_community(&v1).unwrap();
6995
6996        // Simulate the concurrent drive holding the cid (what the live carrier fold does).
6997        migration::test_hold_drive_claim(&v1_cid);
6998        let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6999        assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
7000        // Refused BEFORE minting: no twin, no ledger, nothing to orphan.
7001        assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
7002        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
7003
7004        // Once the drive releases, the wizard runs normally.
7005        migration::test_release_drive_claim(&v1_cid);
7006        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7007        assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
7008    }
7009
7010    /// The flip runs UNDER the twin's follow lock, so it can never straddle a follow
7011    /// worker's whole-row save (which deletes channel rows absent from its pre-flip,
7012    /// channel-less struct — pruning exactly the rows the flip just re-parented).
7013    /// Proves the lock actually serializes rather than being a no-op: with the lock held
7014    /// the wizard cannot reach its flip, and it completes once released.
7015    #[tokio::test]
7016    async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
7017        use crate::community::migration;
7018        let (bed, owner, _member) = TestBed::new();
7019        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7020        // Shared across the spawned wizard, so both halves see the same relay state.
7021        let relay = std::sync::Arc::new(MemoryRelay::new());
7022
7023        bed.swap_to(&owner);
7024        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7025        let v1_cid = v1.id.to_hex();
7026        let v1_channel = v1.channels[0].id.to_hex();
7027        v1.owner_attestation = Some({
7028            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7029                .finalize(&owner.keys).unwrap().as_json()
7030        });
7031        crate::db::community::save_community(&v1).unwrap();
7032
7033        // Phase 1 alone, so the twin's id (and therefore its follow lock) is known before
7034        // the flip runs — exactly what a follow worker would have loaded.
7035        let twin = create_migration_twin(
7036            &*relay, "Guild", bed.relays.clone(), None,
7037            (v1.channels[0].id, "general".to_string()),
7038        ).await.unwrap();
7039        let v2_id = twin.identity.community_id;
7040        let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
7041        crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
7042
7043        // A follow pass is in flight: it holds the lock across its network stage.
7044        let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
7045
7046        let wizard = tokio::spawn({
7047            let relay = relay.clone();
7048            let v1 = v1.clone();
7049            async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
7050        });
7051
7052        // The wizard runs its network phases but must BLOCK at the flip.
7053        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
7054        assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
7055        assert!(
7056            crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
7057            "the fence must not be stamped while the follow lock is held"
7058        );
7059
7060        // The follow pass finishes; the flip proceeds.
7061        drop(held);
7062        let flipped = wizard.await.unwrap().unwrap();
7063        assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
7064        assert_eq!(
7065            crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
7066            Some(v2_hex.as_str()),
7067            "the channel row is stitched to the twin, not pruned"
7068        );
7069    }
7070
7071    /// THE LYNCHPIN: a banned-but-never-cut v1 member CAN open `m` (they hold the v1
7072    /// root — no read-cut ever rotated it), but the wizard cloned the v1 banlist onto the
7073    /// twin, so the ban-gated accept refuses them: no Guestbook Join, no flip, room stays
7074    /// sealed. This is the exact residual JSKitty accepted, proven enforced.
7075    #[tokio::test]
7076    async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
7077        use crate::community::migration;
7078        let (bed, owner, banned) = TestBed::new();
7079        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7080
7081        // Owner's v1 community with the member on the BANLIST (never read-cut: epoch 0).
7082        bed.swap_to(&owner);
7083        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7084        let v1_cid = v1.id.to_hex();
7085        v1.owner_attestation = Some({
7086            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7087                .finalize(&owner.keys).unwrap().as_json()
7088        });
7089        crate::db::community::save_community(&v1).unwrap();
7090        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
7091
7092        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7093
7094        // The banned member holds the same v1 (same root — never cut) and folds the carrier.
7095        bed.swap_to(&banned);
7096        let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7097        m_v1.id = v1.id;
7098        m_v1.server_root_key = v1.server_root_key.clone();
7099        m_v1.channels[0].id = v1.channels[0].id;
7100        m_v1.owner_attestation = v1.owner_attestation.clone();
7101        crate::db::community::save_community(&m_v1).unwrap();
7102        crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
7103
7104        // They hold the pointer AND can open `m` — but the drive is REFUSED at the ban gate.
7105        let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
7106        let payload = migration::parse_migration_payload(&raw).unwrap();
7107        assert!(payload.m.is_some());
7108        let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
7109        assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
7110        assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
7111        assert!(
7112            crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
7113            "banned member never acquires the v2 twin"
7114        );
7115    }
7116
7117    /// Wizard resume never double-mints: a re-run after the TWIN_MINTED ledger row exists
7118    /// completes on the SAME v2 identity — with a NON-vacuous phase-1b re-run (a sibling
7119    /// channel + a banlist entry crash-recovered end-to-end, sibling stitched). Plus the
7120    /// crash-heal: flip landed but the FLIPPED ledger write didn't → re-run reports success.
7121    #[tokio::test]
7122    async fn wizard_resume_continues_on_the_same_twin() {
7123        use crate::community::migration;
7124        let (bed, owner, banned) = TestBed::new();
7125        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7126
7127        bed.swap_to(&owner);
7128        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7129        // A second channel + a banned member make the resumed phase-1b tail REAL work.
7130        let mut sibling = v1.channels[0].clone();
7131        sibling.id = crate::community::ChannelId(crate::community::random_32());
7132        sibling.name = "offtopic".into();
7133        v1.channels.push(sibling.clone());
7134        let v1_cid = v1.id.to_hex();
7135        v1.owner_attestation = Some({
7136            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7137                .finalize(&owner.keys).unwrap().as_json()
7138        });
7139        crate::db::community::save_community(&v1).unwrap();
7140        crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
7141
7142        // Simulate a crash right after the mint: build the twin + ledger TWIN_MINTED, stop
7143        // BEFORE the sibling channel + banlist clone ever ran.
7144        let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
7145        let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
7146        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
7147
7148        // The re-run resumes onto the SAME identity, re-runs 1b, and completes.
7149        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7150        assert_eq!(v2_hex, minted_hex, "no second twin was minted");
7151        let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7152        assert_eq!(ledger_v2, minted_hex);
7153        assert_eq!(phase, migration::PHASE_FLIPPED);
7154        // The crash-recovered sibling stitched too, and the banlist clone landed on the wire
7155        // (folding the twin's control plane yields the banned npub).
7156        assert_eq!(
7157            crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
7158            Some(minted_hex.as_str()),
7159            "sibling channel re-parented by the resumed run"
7160        );
7161        let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7162        let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
7163            .await
7164            .map(|(c, h, b)| (c, h, b))
7165            .unwrap();
7166        assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
7167            "the resumed banlist clone is folded from the twin's wire control plane");
7168
7169        // Crash-heal: roll the ledger back to CARRIER_PUBLISHED (flip landed, ledger behind)
7170        // → the re-run reports SUCCESS and heals, never "already been migrated".
7171        crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7172        let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7173        assert_eq!(healed, minted_hex);
7174        let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7175        assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
7176
7177        // Resume past a SELF-SEAL: a fold sealed the community after the carrier but
7178        // before the flip write (dissolved=1, migrated_to still NULL, ledger at
7179        // CARRIER_PUBLISHED). A wizard resume must NOT read this as a foreign dissolution.
7180        // Reuse THIS bed (a second TestBed would re-lock DB_TEST_GUARD and deadlock) with a
7181        // fresh v1 owned by the same owner.
7182        let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
7183        let v1b_cid = v1b.id.to_hex();
7184        v1b.owner_attestation = Some({
7185            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
7186                .finalize(&owner.keys).unwrap().as_json()
7187        });
7188        crate::db::community::save_community(&v1b).unwrap();
7189        let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
7190        let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
7191        crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7192        crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); // the self-seal
7193        let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
7194        assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
7195        assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
7196    }
7197
7198    /// The birth refound SEEDS the roster: rolling a genesis (epoch 0) twin to epoch 1 with an
7199    /// explicit member list makes those members fold into the memberlist WITHOUT any of them
7200    /// publishing a Join — the anti-ghost-town seed for not-yet-migrated v1 members (who hold
7201    /// no v2 keys). Genesis had no snapshot power; epoch 1 (owner = minting refounder) does.
7202    #[tokio::test]
7203    async fn birth_refound_seeds_an_explicit_roster() {
7204        let (bed, owner, _m) = TestBed::new();
7205        bed.swap_to(&owner);
7206        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7207            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7208        assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
7209        // Two strangers who never join — pure seeded members.
7210        let ghost_a = Keys::generate().public_key();
7211        let ghost_b = Keys::generate().public_key();
7212
7213        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7214        assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
7215
7216        // The memberlist folds all three from the epoch-1 snapshot, though only the owner
7217        // ever published a Join.
7218        let members = memberlist(&bed.relay, &rolled).await.unwrap();
7219        assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
7220        assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
7221
7222        // The compacted control plane still verifies (owner genesis carried to epoch 1) — a
7223        // fresh joiner at epoch 1 folds it. And a genesis-epoch snapshot has NO power: rolling
7224        // a fresh twin's snapshot only counts because the owner minted epoch 1.
7225        let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
7226            .expect("the epoch-1 twin verifies from its compacted control plane");
7227
7228        // RESUME IDEMPOTENCE: a re-call on the already-refounded twin is a no-op (returns
7229        // epoch 1), never a double-advance to epoch 2 — the crash-between-wire-and-ledger case.
7230        let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7231        assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
7232        assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
7233    }
7234
7235    /// A banned entry in the seed list must NOT wedge the verify-back: fold_members
7236    /// subtracts the banlist, so a banned seed is never "readable" — the defensive filter drops
7237    /// it before the snapshot, so the refound still completes instead of aborting forever.
7238    #[tokio::test]
7239    async fn birth_refound_ignores_a_banned_seed_entry() {
7240        let (bed, owner, _m) = TestBed::new();
7241        bed.swap_to(&owner);
7242        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7243            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7244        let good = Keys::generate().public_key();
7245        let banned = Keys::generate();
7246        // Ban `banned` on the twin, then hand refound a seed list that (wrongly) includes them.
7247        set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
7248        let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7249
7250        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
7251            .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
7252        assert_eq!(rolled.root_epoch, Epoch(1));
7253        let members = memberlist(&bed.relay, &rolled).await.unwrap();
7254        assert!(members.contains(&good), "the non-banned seed lands");
7255        assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
7256    }
7257
7258    /// The "late migrator never misses an epoch" property: a SEEDED-but-never-landed
7259    /// member (in the roster only via the birth snapshot, holding no keys, never posted) is a
7260    /// RECIPIENT of a subsequent OWNER refound — so a rotation that happens before they migrate
7261    /// still mints them a rekey blob to walk forward on. Verified by checking the ghost lands
7262    /// in the refound's memberlist-derived recipient set (they get a base-rekey blob).
7263    #[tokio::test]
7264    async fn a_seeded_member_receives_a_later_refound_rekey() {
7265        let (bed, owner, _m) = TestBed::new();
7266        bed.swap_to(&owner);
7267        let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7268            (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7269        let ghost = Keys::generate();
7270        // Birth refound seeds the ghost (never joins, holds no keys).
7271        let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
7272        assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
7273
7274        // A later OWNER refound (epoch 1→2) derives its rekey recipients from memberlist(),
7275        // which folds the snapshot — so the ghost IS a recipient (a base-rekey blob is minted
7276        // for them by construction) AND is re-snapshotted at epoch 2. Surviving in the epoch-2
7277        // memberlist proves both: the refound saw them as a member and carried them forward, so
7278        // a late migrator who opens `m` (epoch 1) can then walk their epoch-2 blob forward.
7279        let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
7280        assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
7281        assert!(
7282            memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
7283            "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
7284        );
7285    }
7286
7287    /// Governance survives migration: a v1 ADMIN is re-granted @admin on the twin (holds
7288    /// MANAGE_ROLES there), while a plain member is not.
7289    #[tokio::test]
7290    async fn v1_admin_stays_admin_across_migration() {
7291        use crate::community::migration;
7292        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
7293        let (bed, owner, admin) = TestBed::new();
7294        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7295
7296        bed.swap_to(&owner);
7297        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7298        let v1_cid = v1.id.to_hex();
7299        v1.owner_attestation = Some({
7300            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7301                .finalize(&owner.keys).unwrap().as_json()
7302        });
7303        crate::db::community::save_community(&v1).unwrap();
7304        // v1 governance: one Admin role, granted to `admin`.
7305        let admin_role = Role::admin("a1".repeat(32));
7306        let roles = CommunityRoles {
7307            roles: vec![admin_role.clone()],
7308            grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
7309        };
7310        crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
7311
7312        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7313        let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
7314
7315        // Fold the twin's authority from the wire: the admin holds MANAGE_ROLES, a stranger doesn't.
7316        let authority = fetch_authority(&bed.relay, &twin).await;
7317        assert!(
7318            authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7319            "the v1 admin is an admin on the v2 twin"
7320        );
7321        assert!(
7322            !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7323            "a non-admin gains no authority"
7324        );
7325    }
7326
7327    /// A device that never folded the dissolution tombstone still heals.
7328    ///
7329    /// Live two-device wedge: the control fold is what seals a migrated-away community,
7330    /// and the boot control probe can veto that fold indefinitely (it is `since`-windowed
7331    /// over the CONTROL plane, while the tombstone lives at the DISSOLVED coordinate). The
7332    /// second device therefore sat UNSEALED, which used to exclude it from the sweep
7333    /// (`dissolved = 1`) AND from the flip retry (no pointer) — the one state that most
7334    /// needed probing was the one nothing probed, so it stayed on v1 forever.
7335    #[tokio::test]
7336    async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
7337        use crate::community::migration;
7338        let (bed, owner, member) = TestBed::new();
7339        bed.swap_to(&owner);
7340
7341        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7342        let v1_cid = v1.id.to_hex();
7343        v1.owner_attestation = Some({
7344            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7345                .finalize(&owner.keys).unwrap().as_json()
7346        });
7347        crate::db::community::save_community(&v1).unwrap();
7348
7349        // The owner migrates on their FIRST device: this publishes the carrier tombstone.
7350        let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7351        let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7352
7353        // A MEMBER's device that holds the v1 community and never folded the tombstone:
7354        // unsealed, pointer-less, unchecked — exactly the wedged shape.
7355        bed.swap_to(&member);
7356        crate::db::community::save_community(&v1).unwrap();
7357        assert!(
7358            !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
7359            "precondition: the wedged device has NOT sealed its v1 row"
7360        );
7361
7362        // It IS a sweep candidate now (the fix); before, `dissolved = 1` excluded it.
7363        assert!(
7364            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7365            "an unsealed, migrated-away v1 must be probed"
7366        );
7367
7368        migration::sweep_dissolved_for_migration(&bed.relay).await;
7369
7370        // The sweep found the carrier, sealed the v1 row, and flipped it to the twin.
7371        assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
7372        assert_eq!(
7373            crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7374            Some(v2_hex.as_str()),
7375            "flipped to the same twin the first device produced"
7376        );
7377        assert!(
7378            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7379            "and the sweep converges — no re-probing forever"
7380        );
7381    }
7382
7383    /// The sweep converges on a PLAIN dissolution (owner-signed, no payload) but a
7384    /// non-owner tombstone (member-mintable) must NOT mark it checked — else a partial-relay
7385    /// probe returning only a stranger's record would permanently stop the sweep before the
7386    /// owner's real carrier is ever fetched.
7387    #[tokio::test]
7388    async fn sweep_marks_checked_only_on_an_owner_tombstone() {
7389        use crate::community::migration;
7390        let (bed, owner, stranger) = TestBed::new();
7391
7392        bed.swap_to(&owner);
7393        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7394        let v1_cid = v1.id.to_hex();
7395        v1.owner_attestation = Some({
7396            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7397                .finalize(&owner.keys).unwrap().as_json()
7398        });
7399        crate::db::community::save_community(&v1).unwrap();
7400
7401        // A STRANGER publishes a (payload-less) tombstone at the dissolved coordinate, and
7402        // the community is locally sealed (as if folded on an old build) but not yet checked.
7403        let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
7404        let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
7405        bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
7406        crate::db::community::set_community_dissolved(&v1_cid).unwrap();
7407
7408        // Sweep: the only record is a stranger's → NOT marked checked (still a candidate).
7409        migration::sweep_dissolved_for_migration(&bed.relay).await;
7410        assert!(
7411            crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7412            "a stranger-only probe must not converge the sweep"
7413        );
7414
7415        // Now the OWNER publishes a plain dissolution → sweep marks it checked.
7416        let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
7417        let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
7418        bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
7419        migration::sweep_dissolved_for_migration(&bed.relay).await;
7420        assert!(
7421            !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7422            "an owner plain-dissolution converges the sweep"
7423        );
7424    }
7425
7426    /// Wizard preflight refuses before the timelock and for non-owners.
7427    #[tokio::test]
7428    async fn wizard_preflight_gates_timelock_and_ownership() {
7429        use crate::community::migration;
7430        let (bed, owner, _member) = TestBed::new();
7431        bed.swap_to(&owner);
7432        let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7433        v1.owner_attestation = Some({
7434            crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
7435                .finalize(&owner.keys).unwrap().as_json()
7436        });
7437        crate::db::community::save_community(&v1).unwrap();
7438
7439        // Before the unlock → refused, nothing published.
7440        let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
7441        assert!(err.contains("not unlocked"), "{err}");
7442        assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
7443    }
7444
7445    #[tokio::test]
7446    async fn public_link_full_loop() {
7447        let (bed, owner, member) = TestBed::new();
7448
7449        bed.swap_to(&owner);
7450        let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
7451        let general = community.channels[0].id;
7452        send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
7453        // Mint a shareable link (a non-stock relay so the fragment carries it).
7454        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7455        assert!(link.url.starts_with("https://vectorapp.io/invite/"));
7456        assert!(link.url.contains('#'), "the fragment carries the token");
7457
7458        // Member joins purely from the URL string.
7459        bed.swap_to(&member);
7460        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7461        assert_eq!(joined.id().0, community.id().0);
7462        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
7463    }
7464
7465    #[test]
7466    fn bundle_of_snapshots_the_held_icon() {
7467        let owner = Keys::generate();
7468        let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
7469        let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
7470        let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
7471        c.icon = Some(icon.clone());
7472        let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
7473        assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
7474    }
7475
7476    #[test]
7477    fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
7478        // follow_rekeys' fetch fan AND streamauth's plane registration share
7479        // this. A channel rekey rides the PRIOR root (CORD-06 D2), so the set
7480        // MUST include archived roots or an AUTH-gated relay never serves the
7481        // rotation crate → the channel stalls at its old epoch.
7482        let (_tmp, _guard, _owner) = init_test_db();
7483        let cur_root = [9u8; 32];
7484        let cid = crate::community::CommunityId([1u8; 32]);
7485        let cid_hex = cid.to_hex();
7486
7487        // No archives yet → just the current root.
7488        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7489        assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
7490
7491        // Archive two prior roots (freshest-first ordering is asserted below).
7492        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
7493        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
7494        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7495        assert_eq!(roots[0], cur_root, "current root leads");
7496        assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
7497        assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
7498        // Freshest-archived-first (epoch 1 before epoch 0).
7499        assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
7500
7501        // A stored root equal to the CURRENT one must not duplicate.
7502        crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
7503        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7504        assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
7505
7506        // Cap: many archives truncate to MAX_ADDRESSING_ROOTS.
7507        for e in 3..20u64 {
7508            crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
7509        }
7510        let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7511        assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
7512    }
7513
7514    #[tokio::test]
7515    async fn public_link_preview_shows_live_name_and_icon_without_joining() {
7516        let (bed, owner, member) = TestBed::new();
7517
7518        bed.swap_to(&owner);
7519        let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
7520        // The icon lives on the Control Plane, never in the bundle — publish it
7521        // as a metadata edition so the preview must FOLD to see it.
7522        let icon = control::ImageRef {
7523            url: "https://blossom.example/soap".into(),
7524            key: "k".into(),
7525            nonce: "n".into(),
7526            hash: "h".into(),
7527            extra: Default::default(),
7528        };
7529        let mut meta = community.metadata();
7530        meta.icon = Some(icon.clone());
7531        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7532        // An any-host base — the naddr#fragment payload is domain-agnostic.
7533        let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
7534
7535        // A NON-member previews: the real name + the live icon, nothing persisted.
7536        bed.swap_to(&member);
7537        let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
7538        assert_eq!(preview.name, "Soapbox");
7539        assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
7540        assert!(
7541            crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
7542            "previewing must not persist a membership"
7543        );
7544    }
7545
7546    #[tokio::test]
7547    async fn a_previewed_join_reuses_the_verified_fold() {
7548        let (bed, owner, member) = TestBed::new();
7549        bed.swap_to(&owner);
7550        let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
7551        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7552
7553        bed.swap_to(&member);
7554        let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
7555        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7556        assert_eq!(joined.id().0, community.id().0);
7557        assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
7558        // The slot was CONSUMED by the join — proving the handoff path ran (a
7559        // verify re-walk would have left the preview's entry in place).
7560        assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
7561    }
7562
7563    #[tokio::test]
7564    async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
7565        let (bed, owner, member) = TestBed::new();
7566        bed.swap_to(&owner);
7567        let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
7568        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7569
7570        bed.swap_to(&member);
7571        let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7572
7573        // Seed from zero: the stored fold equals the authoritative live fold.
7574        let session = SessionGuard::capture();
7575        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
7576        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7577        let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
7578        assert!(cursor > 0, "the cursor advanced past zero");
7579        let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
7580        let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
7581        assert_eq!(stored, live, "stored fold == live fold after the seed");
7582        assert!(stored.contains(&member.keys.public_key()));
7583
7584        // Nothing new on the plane → an idle re-sync folds nothing.
7585        assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
7586
7587        // The owner kicks the member; a CURSOR catch-up folds the kick in — no full walk.
7588        bed.swap_to(&owner);
7589        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7590        bed.swap_to(&member);
7591        let session = SessionGuard::capture();
7592        assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
7593        assert!(
7594            !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
7595            "an owner kick removes the member from the stored fold"
7596        );
7597    }
7598
7599    #[tokio::test]
7600    async fn a_preview_then_revoke_still_refuses_the_join() {
7601        let (bed, owner, member) = TestBed::new();
7602        bed.swap_to(&owner);
7603        let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
7604        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7605
7606        // Member previews (warming the verified handoff), THEN the owner revokes.
7607        bed.swap_to(&member);
7608        let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
7609        assert_eq!(p.name, "RevokeRace");
7610        bed.swap_to(&owner);
7611        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7612        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7613
7614        // The join MUST refuse: the handoff skips only the root re-verify, never
7615        // the bundle re-fetch that carries the revocation gate.
7616        bed.swap_to(&member);
7617        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7618        assert!(err.contains("revoked"), "got: {err}");
7619    }
7620
7621    #[tokio::test]
7622    async fn a_revoked_link_refuses_to_join() {
7623        let (bed, owner, member) = TestBed::new();
7624        bed.swap_to(&owner);
7625        let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
7626        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7627        // Owner retires the link (re-posts the coordinate as a tombstone).
7628        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7629        bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7630
7631        bed.swap_to(&member);
7632        let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7633        assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
7634    }
7635
7636    #[tokio::test]
7637    async fn an_expired_direct_invite_refuses_to_join() {
7638        let (bed, owner, member) = TestBed::new();
7639        bed.swap_to(&owner);
7640        let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
7641        // Hand-mint an invite that expired in the past.
7642        let inviter = owner.keys.clone();
7643        let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
7644        bundle.expires_at = Some(1_000); // unix ms, long past
7645        let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
7646        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7647
7648        bed.swap_to(&member);
7649        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7650        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
7651        assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
7652    }
7653
7654    #[tokio::test]
7655    async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
7656        // The revocation-durability fix: if ANY signer-valid tombstone is among the
7657        // fetched events, refuse — even when a Live bundle is returned FIRST (the
7658        // production union has no newest-first sort, so a stale relay's Live can lead).
7659        let (bed, owner, member) = TestBed::new();
7660        bed.swap_to(&owner);
7661        let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
7662        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7663        let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7664
7665        // A relay union that hands back [Live, tombstone] — Live FIRST. Old
7666        // `events.first()` would join the Live; the scan-all fix must refuse.
7667        let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
7668
7669        bed.swap_to(&member);
7670        let err = accept_public_link(&union, &link.url).await.unwrap_err();
7671        assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
7672    }
7673
7674    #[test]
7675    fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
7676        // The accept-side DoS bound: from_bundle (which accept_bundle calls)
7677        // rejects a >256-channel bundle via validate() BEFORE the Vec allocation.
7678        // (The Direct-Invite wire path is additionally bounded by NIP-44's 64KB
7679        // cap, which trips even earlier — but the count guard is the real defense
7680        // for the single-layer public-link bundle.)
7681        let owner = Keys::generate();
7682        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7683        let hex = crate::simd::hex::bytes_to_hex_32;
7684        let root = [0x11u8; 32];
7685        let mut bundle = CommunityInvite {
7686            community_id: hex(&identity.community_id.0),
7687            owner: hex(&identity.owner_xonly),
7688            owner_salt: hex(&identity.owner_salt),
7689            community_root: hex(&root),
7690            root_epoch: 0,
7691            channels: vec![],
7692            relays: vec!["wss://r".into()],
7693            name: "X".into(),
7694            icon: None,
7695            expires_at: None,
7696            creator_npub: None,
7697            label: None,
7698            extra: Default::default(),
7699        };
7700        bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
7701            .map(|i| {
7702                let mut id = [0u8; 32];
7703                id[..8].copy_from_slice(&(i as u64).to_be_bytes());
7704                invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
7705            })
7706            .collect();
7707        assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
7708    }
7709
7710    #[tokio::test]
7711    async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
7712        // The SessionGuard straddle: a public-link accept fetches then saves. If the
7713        // account swaps in that window, the join must abort — never write A's
7714        // community into B's DB. SwapMidFetch bumps the session generation during
7715        // the fetch await, exactly as a real swap_session would.
7716        let (bed, owner, member) = TestBed::new();
7717        bed.swap_to(&owner);
7718        let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
7719        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7720        // A fresh swap-injecting transport holding the same bundle event.
7721        let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
7722        swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
7723
7724        bed.swap_to(&member);
7725        let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
7726        assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
7727        assert!(
7728            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7729            "the aborted join wrote nothing to the (member) account DB"
7730        );
7731    }
7732
7733    #[tokio::test]
7734    async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
7735        // The owner is derived from the self-certifying community_id, so the
7736        // memberlist includes them independent of any Guestbook fetch.
7737        let (_tmp, _guard, owner) = init_test_db();
7738        let relay = MemoryRelay::new();
7739        let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
7740        // A memberlist over an EMPTY guestbook (fetch a community-relay-less view)
7741        // still contains the owner.
7742        let empty = MemoryRelay::new();
7743        let members = memberlist(&empty, &community).await.unwrap();
7744        assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
7745    }
7746
7747    #[tokio::test]
7748    async fn an_expiring_minted_invite_refuses_after_the_deadline() {
7749        // The mint path can now produce an expiring invite, and the accept gate
7750        // trips on it (end-to-end through the real service, not a hand-built bundle).
7751        let (bed, owner, member) = TestBed::new();
7752        bed.swap_to(&owner);
7753        let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
7754        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
7755            .await
7756            .unwrap();
7757
7758        bed.swap_to(&member);
7759        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7760        assert!(
7761            accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
7762            "a minted expiring invite refuses past its deadline"
7763        );
7764    }
7765
7766    #[tokio::test]
7767    async fn a_member_who_leaves_drops_from_the_memberlist() {
7768        let (bed, owner, member) = TestBed::new();
7769        bed.swap_to(&owner);
7770        let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
7771        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7772
7773        bed.swap_to(&member);
7774        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7775        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7776        // Let the leave land strictly after the join.
7777        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
7778        leave_community(&bed.relay, &joined).await.unwrap();
7779
7780        bed.swap_to(&owner);
7781        let members = memberlist(&bed.relay, &community).await.unwrap();
7782        assert!(members.contains(&owner.keys.public_key()));
7783        assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7784    }
7785
7786    #[tokio::test]
7787    async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7788        // Multi-account isolation: after the swap, the member's DB holds nothing
7789        // of the owner's community — the dual-stack storage is per-account.
7790        let (bed, owner, member) = TestBed::new();
7791        bed.swap_to(&owner);
7792        let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7793        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7794
7795        bed.swap_to(&member);
7796        assert!(
7797            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7798            "the owner's community must be invisible in the member's account DB"
7799        );
7800        assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7801    }
7802
7803    // ── Live control-follow ──────────────────────────────────────────────────
7804
7805    /// Publish an owner-grammar channel edition straight to the control plane,
7806    /// signed by `signer` (the owner for a legit edit, a stranger for the
7807    /// authority test). `version`/`deleted` drive add-vs-rename-vs-delete.
7808    /// The entity's current head `self_hash` on the relay (highest version wins),
7809    /// so a helper can chain a new edition the way a real owner client does.
7810    async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7811        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7812        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7813        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7814        let mut head: Option<(u64, [u8; 32])> = None;
7815        for w in &wraps {
7816            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7817                if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7818                    head = Some((ed.version, ed.self_hash));
7819                }
7820            }
7821        }
7822        head.map(|(_, h)| h)
7823    }
7824
7825    /// The `vac` a non-owner signer must attach, read off the Grant they were
7826    /// given on the relay (CORD-04 §5). The owner cites nothing. Mirrors what a
7827    /// real client does via `my_authority_citation`, so the fixtures publish the
7828    /// shape Vector actually emits.
7829    async fn cite_on_relay(
7830        relay: &MemoryRelay,
7831        community: &CommunityV2,
7832        signer: &Keys,
7833    ) -> Option<crate::community::edition::AuthorityCitation> {
7834        if community.owner().ok() == Some(signer.public_key()) {
7835            return None;
7836        }
7837        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7838        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7839        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7840        let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7841        let mut head: Option<(u64, [u8; 32])> = None;
7842        for w in &wraps {
7843            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7844                if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7845                    head = Some((ed.version, ed.self_hash));
7846                }
7847            }
7848        }
7849        head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7850    }
7851
7852    async fn publish_channel_edition(
7853        relay: &MemoryRelay,
7854        community: &CommunityV2,
7855        signer: &Keys,
7856        channel_id: &ChannelId,
7857        name: &str,
7858        private: bool,
7859        version: u64,
7860        deleted: bool,
7861    ) {
7862        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7863        let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7864        let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7865        let content = serde_json::to_string(&meta).unwrap();
7866        let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7867        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7868        relay.publish(&wrap, &community.relays).await.unwrap();
7869    }
7870
7871    /// Publish an owner-grammar community-metadata edition (rename etc.), chained
7872    /// to the current relay head like a real owner client.
7873    async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7874        publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7875    }
7876
7877    /// As [`publish_community_meta`] with an explicit timestamp, for tests that need
7878    /// relay-side newest-first ordering (paging/eviction scenarios).
7879    async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7880        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7881        let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7882        let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7883        let content = serde_json::to_string(&meta).unwrap();
7884        let cite = cite_on_relay(relay, community, signer).await;
7885        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());
7886        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7887        relay.publish(&wrap, &community.relays).await.unwrap();
7888    }
7889
7890    #[test]
7891    fn metadata_apply_captures_undriven_fields_for_republish() {
7892        let owner = Keys::generate();
7893        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7894        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7895        let general = held.channels[0].id;
7896
7897        // A foreign vsk-0 head carrying custom + unknown fields folds them in…
7898        let mut custom = serde_json::Map::new();
7899        custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7900        let mut extra = serde_json::Map::new();
7901        extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7902        let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7903        assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7904        assert_eq!(held.meta_custom, Some(custom.clone()));
7905        assert_eq!(held.meta_extra, extra);
7906        // …and the next local edit's base document republishes them verbatim.
7907        assert_eq!(held.metadata().custom, Some(custom));
7908        assert_eq!(held.metadata().extra, held.meta_extra);
7909
7910        // Same contract for a vsk-2 channel head (voice included).
7911        let mut ch_custom = serde_json::Map::new();
7912        ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7913        let ch_meta = control::ChannelMetadata {
7914            name: "general".into(),
7915            private: false,
7916            voice: Some(true),
7917            deleted: None,
7918            custom: Some(ch_custom.clone()),
7919            extra: Default::default(),
7920        };
7921        assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7922        let ch = held.channel(&general).unwrap();
7923        assert_eq!(ch.voice, Some(true));
7924        assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7925        let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7926        assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7927        assert_eq!(rename.custom, Some(ch_custom));
7928    }
7929
7930    #[test]
7931    fn community_metadata_apply_sets_and_clears_images() {
7932        let owner = Keys::generate();
7933        let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7934        let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7935
7936        let icon = control::ImageRef {
7937            url: "https://blossom.example/i".into(),
7938            key: "k".into(),
7939            nonce: "n".into(),
7940            hash: "h".into(),
7941            extra: Default::default(),
7942        };
7943        let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7944        assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7945        assert_eq!(held.icon.as_ref(), Some(&icon));
7946
7947        // An edition is the FULL document: a head without the icon removes it.
7948        let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7949        assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7950        assert_eq!(held.icon, None);
7951    }
7952
7953    /// Publish a Role edition (vsk 1) signed by `signer`, chained to the current head.
7954    async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7955        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7956        let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7957        let prev = head_hash_on_relay(relay, community, &role_id).await;
7958        let content = crate::community::v2::roles::role_content_json(role).unwrap();
7959        let cite = cite_on_relay(relay, community, signer).await;
7960        let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7961        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7962        relay.publish(&wrap, &community.relays).await.unwrap();
7963    }
7964
7965    /// Publish a Grant edition (vsk 3) signed by `signer`, at grant_locator(cid, member).
7966    async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7967        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7968        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7969        let prev = head_hash_on_relay(relay, community, &eid).await;
7970        let grant = MemberGrant { member: member.to_hex(), role_ids };
7971        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7972        let cite = cite_on_relay(relay, community, signer).await;
7973        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7974        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7975        relay.publish(&wrap, &community.relays).await.unwrap();
7976    }
7977
7978    /// Publish a Banlist edition (vsk 4) signed by `signer`, at banlist_locator(cid).
7979    async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7980        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7981        let eid = crate::community::v2::derive::banlist_locator(community.id());
7982        let prev = head_hash_on_relay(relay, community, &eid).await;
7983        let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7984        let cite = cite_on_relay(relay, community, signer).await;
7985        let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7986        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7987        relay.publish(&wrap, &community.relays).await.unwrap();
7988    }
7989
7990    fn admin_role(role_id: &str, perms: u64) -> Role {
7991        Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7992    }
7993
7994    // ── CORD-04 §1 author-aware fold: a seat-holder (holds community_root, so can seal
7995    // any control edition) must not be able to SUPPRESS a role or grant by forging a
7996    // higher version at its coordinate. Owner-only signers mask this entirely, so every
7997    // attacker below signs as a NON-owner member.
7998
7999    #[tokio::test]
8000    async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
8001        let (bed, owner, attacker) = TestBed::new();
8002        bed.swap_to(&owner);
8003        let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
8004        let victim = Keys::generate().public_key();
8005        grant_admin(&bed.relay, &community, &victim).await.unwrap();
8006
8007        // The admin role sits at a deterministic, publicly-computable coordinate.
8008        let admin_rid = fetch_authority(&bed.relay, &community)
8009            .await
8010            .roles
8011            .roles
8012            .iter()
8013            .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
8014            .unwrap()
8015            .role_id
8016            .clone();
8017        // Attacker forges v2 of that exact role, stripping its powers.
8018        publish_role(
8019            &bed.relay,
8020            &community,
8021            &attacker.keys,
8022            &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
8023            2,
8024        )
8025        .await;
8026
8027        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8028        assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
8029        assert!(
8030            authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
8031            "the floor advances only to the AUTHORIZED head (owner v1)"
8032        );
8033        assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
8034    }
8035
8036    #[tokio::test]
8037    async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
8038        let (bed, owner, attacker) = TestBed::new();
8039        bed.swap_to(&owner);
8040        let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
8041        let victim = Keys::generate();
8042        grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
8043
8044        // Attacker forges a higher-version EMPTY grant at the victim's grant coordinate.
8045        publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
8046
8047        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8048        assert!(
8049            authority.roles.is_admin(&victim.public_key().to_hex()),
8050            "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
8051        );
8052    }
8053
8054    #[tokio::test]
8055    async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
8056        let (bed, owner, attacker) = TestBed::new();
8057        bed.swap_to(&owner);
8058        let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
8059        let victim = Keys::generate().public_key();
8060        grant_admin(&bed.relay, &community, &victim).await.unwrap();
8061
8062        // Low-id roles that WOULD evict the admin from a pre-authorize cap — but they're
8063        // unauthorized, so the post-authorize cap never sees them.
8064        for i in 0u8..6 {
8065            let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
8066            publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8067        }
8068
8069        let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8070        assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
8071        assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
8072    }
8073
8074    /// A canonical (order-independent) fingerprint of an AuthoritySet's authorized
8075    /// roster + banlist — two clients converge iff these match.
8076    fn authority_fingerprint(a: &AuthoritySet) -> String {
8077        let mut roles = a.roles.roles.clone();
8078        roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
8079        let mut grants = a.roles.grants.clone();
8080        for g in &mut grants {
8081            g.role_ids.sort();
8082        }
8083        grants.sort_by(|x, y| x.member.cmp(&y.member));
8084        let banned: Vec<&String> = a.banned.iter().collect();
8085        serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
8086    }
8087
8088    #[tokio::test]
8089    async fn the_v2_authority_fold_is_order_independent() {
8090        // THE core consensus property: two honest clients that receive the SAME
8091        // control editions in DIFFERENT arrival orders must resolve the IDENTICAL
8092        // authorized roster + banlist (author-aware select_authorized + banlist
8093        // fold + cap, all deterministic). A divergence here would fork the
8094        // community's moderation state between honest members.
8095        let (bed, owner, _a) = TestBed::new();
8096        bed.swap_to(&owner);
8097        let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
8098
8099        // A rich control plane: two admins, an extra role, two grants (one of them a
8100        // grant to a member the owner then bans), a banlist, a rename, a channel.
8101        let admin1 = Keys::generate().public_key();
8102        let admin2 = Keys::generate().public_key();
8103        grant_admin(&bed.relay, &community, &admin1).await.unwrap();
8104        grant_admin(&bed.relay, &community, &admin2).await.unwrap();
8105        let mod_rid = "5c".repeat(32);
8106        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
8107        let member = Keys::generate().public_key();
8108        publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
8109        let banned_member = Keys::generate().public_key();
8110        publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
8111        set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
8112        let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
8113        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8114        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
8115
8116        let editions = fetch_control(&bed.relay, &community).await;
8117        let floors = load_floors(&community);
8118        assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
8119
8120        let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
8121
8122        // Fold under many arrival permutations: reversed, and several deterministic
8123        // rotations/interleavings. Every one must match the baseline.
8124        let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
8125        let mut rev = editions.clone();
8126        rev.reverse();
8127        orders.push(rev);
8128        for shift in [1usize, 3, 5, 7] {
8129            let n = editions.len();
8130            orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
8131        }
8132        // A deterministic "shuffle": interleave from both ends.
8133        let mut zip = Vec::with_capacity(editions.len());
8134        let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
8135        while lo <= hi {
8136            zip.push(editions[lo as usize].clone());
8137            if lo != hi {
8138                zip.push(editions[hi as usize].clone());
8139            }
8140            lo += 1;
8141            hi -= 1;
8142        }
8143        orders.push(zip);
8144
8145        for (i, order) in orders.iter().enumerate() {
8146            let got = authority_fingerprint(&fold_authority(&community, order, &floors));
8147            assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
8148        }
8149        // Sanity: the fingerprint reflects real state (the banned member is out, the
8150        // honest admins are in).
8151        assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
8152        assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
8153    }
8154
8155    /// A transport that ACKs publishes but ERRORS every fetch — a relay outage / withhold.
8156    struct FetchErrors(MemoryRelay);
8157    #[async_trait::async_trait]
8158    impl crate::community::transport::Transport for FetchErrors {
8159        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
8160        async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
8161            self.0.publish(e, r).await
8162        }
8163        async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
8164            Err("relay down".to_string())
8165        }
8166        async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
8167            self.0.publish_durable(e, r).await
8168        }
8169    }
8170
8171    #[tokio::test]
8172    async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
8173        let (bed, owner, victim) = TestBed::new();
8174        bed.swap_to(&owner);
8175        let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
8176        let victim_hex = victim.keys.public_key().to_hex();
8177        // A ban is persisted locally (as a completed set_banlist + follow leaves it).
8178        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8179        crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
8180
8181        // A relay that ERRORS on fetch must degrade FAIL-SAFE: retain the ban, never
8182        // return an empty banlist (which would silently un-ban on withheld data).
8183        let down = FetchErrors(MemoryRelay::new());
8184        let view = fetch_authority(&down, &community).await;
8185        assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
8186    }
8187
8188    #[tokio::test]
8189    async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
8190        let (bed, owner, _m) = TestBed::new();
8191        bed.swap_to(&owner);
8192        let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
8193        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8194        let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
8195        let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
8196
8197        // Full state on relay1: an Admin role + two grants → both fold + persist as admins.
8198        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8199        publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8200        publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
8201        let session = crate::state::SessionGuard::capture();
8202        follow_control(&bed.relay, &community, &session).await.unwrap();
8203        assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
8204
8205        // relay2 serves A's grant but NOT the role (aged out of the window): the fold
8206        // drops both admins yet raises no gap. The completeness gate must RETAIN the
8207        // stored roster rather than persist the lossy one.
8208        let relay2 = MemoryRelay::new();
8209        publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8210        follow_control(&relay2, &community, &session).await.unwrap();
8211        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
8212        assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
8213    }
8214
8215    #[tokio::test]
8216    async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
8217        // CORD-04 §5 covers EVERY control entity, not just the delegation chain.
8218        // Vector already gated roles and grants in-fold; metadata, channels and
8219        // the banlist resolved on permission alone, so a client one sweep behind
8220        // honored an edit from an admin whose demotion it had not read yet.
8221        let (_tmp, _guard, owner) = init_test_db();
8222        let relay = MemoryRelay::new();
8223        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8224        let admin = Keys::generate();
8225        let rid = "a7".repeat(32);
8226        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
8227        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
8228
8229        // The admin acts WITHOUT citing (what every pre-citation client emitted).
8230        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8231        let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
8232        let rumor = control::build_edition_rumor(
8233            admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
8234            head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
8235            &serde_json::to_string(&meta).unwrap(), 1_000, None,
8236        );
8237        let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8238        relay.publish(&wrap, &community.relays).await.unwrap();
8239
8240        let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
8241        let victim = Keys::generate().public_key().to_hex();
8242        let ban_rumor = control::build_edition_rumor(
8243            admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
8244            &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
8245        );
8246        let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8247        relay.publish(&ban_wrap, &community.relays).await.unwrap();
8248
8249        let session = SessionGuard::capture();
8250        let updated = follow_control(&relay, &community, &session).await.unwrap();
8251        assert!(
8252            updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
8253            "an uncited metadata edit must not be honored",
8254        );
8255        let authority = fetch_authority(&relay, &community).await;
8256        assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
8257        // The positive case (this same admin, citing, lands) is
8258        // `an_authorized_admin_edits_metadata_but_a_demoted_one_cannot` — its
8259        // helper cites, so it proves the gate is the CITATION and not the
8260        // permission. Re-proving it here would need a fresh chain anyway: a
8261        // cited edition chaining onto the rejected one above is gapped, not
8262        // refused.
8263    }
8264
8265    #[tokio::test]
8266    async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
8267        // CORD-04 §5: an admin holding MANAGE_METADATA renames the community; once the
8268        // owner revokes the grant, the (now unauthorized) admin's further edit drops
8269        // and the name holds at the last authorized state.
8270        let (_tmp, _guard, owner) = init_test_db();
8271        let relay = MemoryRelay::new();
8272        let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8273        let admin = Keys::generate();
8274        let rid = "a1".repeat(32);
8275        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8276        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8277        publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
8278
8279        let session = SessionGuard::capture();
8280        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
8281        assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
8282
8283        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke
8284        publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
8285        let _ = follow_control(&relay, &community, &session).await.unwrap();
8286        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8287        assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
8288    }
8289
8290    #[tokio::test]
8291    async fn a_roleless_member_cannot_edit_metadata() {
8292        let (_tmp, _guard, _owner) = init_test_db();
8293        let relay = MemoryRelay::new();
8294        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8295        let stranger = Keys::generate();
8296        publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
8297        let session = SessionGuard::capture();
8298        assert!(
8299            follow_control(&relay, &community, &session).await.unwrap().is_none(),
8300            "a roleless member's metadata edit never folds"
8301        );
8302    }
8303
8304    #[tokio::test]
8305    async fn a_self_signed_grant_is_not_authority() {
8306        // The self-promotion defense: a member self-signs both a role and a grant of
8307        // it to themselves. authorize_delegation drops both (their signer never traces
8308        // to the owner), so their metadata edit stays unauthorized.
8309        let (_tmp, _guard, _owner) = init_test_db();
8310        let relay = MemoryRelay::new();
8311        let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
8312        let rogue = Keys::generate();
8313        let rid = "b2".repeat(32);
8314        publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8315        publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
8316        publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
8317        let session = SessionGuard::capture();
8318        assert!(
8319            follow_control(&relay, &community, &session).await.unwrap().is_none(),
8320            "a self-signed grant confers no authority"
8321        );
8322    }
8323
8324    #[tokio::test]
8325    async fn the_banlist_is_enforced_only_from_a_ban_holder() {
8326        let (_tmp, _guard, owner) = init_test_db();
8327        let relay = MemoryRelay::new();
8328        let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
8329        let target = "cc".repeat(32);
8330
8331        // A non-BAN-holder's banlist edition is folded but NOT enforced.
8332        let rogue = Keys::generate();
8333        publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
8334        let floors = load_floors(&community);
8335        let editions = fetch_control(&relay, &community).await;
8336        let authority = fold_authority(&community, &editions, &floors);
8337        assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
8338
8339        // The owner (supreme, holds BAN) bans the target: now enforced.
8340        publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
8341        let editions = fetch_control(&relay, &community).await;
8342        let authority = fold_authority(&community, &editions, &floors);
8343        assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
8344    }
8345
8346    #[tokio::test]
8347    async fn a_banned_admin_loses_all_authority() {
8348        // CORD-04 §4: a banned npub vanishes — even holding an un-stripped grant, a
8349        // banned admin's authority is dropped and their edits refused.
8350        let (_tmp, _guard, owner) = init_test_db();
8351        let relay = MemoryRelay::new();
8352        let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
8353        let admin = Keys::generate();
8354        let rid = "e5".repeat(32);
8355        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8356        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8357        publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; // ban, grant left intact
8358        publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
8359
8360        let session = SessionGuard::capture();
8361        assert!(
8362            follow_control(&relay, &community, &session).await.unwrap().is_none(),
8363            "a banned admin's edit is dropped even with an unstripped grant"
8364        );
8365        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8366        assert!(authority.banned.contains(&admin.public_key().to_hex()));
8367        assert!(
8368            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
8369            "a banned admin holds no bit"
8370        );
8371    }
8372
8373    #[tokio::test]
8374    async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
8375        // CORD-04 §3/§5: BAN needs the bit AND a strict outrank of the target. A mod
8376        // (pos 2, holds BAN) can ban a lower member but NOT a superior admin (pos 1)
8377        // and NOT the owner (supreme, unbannable).
8378        let (_tmp, _guard, owner) = init_test_db();
8379        let relay = MemoryRelay::new();
8380        let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
8381        let admin = Keys::generate();
8382        let moder = Keys::generate();
8383        let stranger = Keys::generate();
8384        let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
8385        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;
8386        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;
8387        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
8388        publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
8389        publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
8390
8391        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8392        assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
8393        assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
8394        assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
8395    }
8396
8397    #[tokio::test]
8398    async fn an_unauthorized_higher_banlist_cannot_unban() {
8399        // CORD-04 §4 anti-roster fail-CLOSED: a rogue's higher-version empty banlist
8400        // must not erase the owner's ban (author-aware head selection + persisted
8401        // banlist retention).
8402        let (_tmp, _guard, owner) = init_test_db();
8403        let relay = MemoryRelay::new();
8404        let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
8405        let target = "cc".repeat(32);
8406        publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
8407        let session = SessionGuard::capture();
8408        follow_control(&relay, &community, &session).await.unwrap(); // persists the ban
8409
8410        let rogue = Keys::generate();
8411        publish_banlist(&relay, &community, &rogue, &[], 2).await; // unauthorized higher, empty
8412        let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8413        assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
8414    }
8415
8416    #[tokio::test]
8417    async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
8418        // CORD-02 §8: create publishes the 13302; a fresh device (community dropped
8419        // locally, the 13302 + genesis still on the relay) rehydrates it on sync.
8420        let (_tmp, _guard, _owner) = init_test_db();
8421        let relay = MemoryRelay::new();
8422        let relays = vec!["wss://r".to_string()];
8423        let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
8424        crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
8425        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
8426
8427        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8428        assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
8429        assert_eq!(rehydrated[0].id().0, community.id().0);
8430        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
8431    }
8432
8433    #[tokio::test]
8434    async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
8435        let (_tmp, _guard, _owner) = init_test_db();
8436        let relay = MemoryRelay::new();
8437        let relays = vec!["wss://r".to_string()];
8438        let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
8439        leave_community(&relay, &community).await.unwrap(); // tombstones the 13302 + deletes
8440
8441        let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8442        assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
8443    }
8444
8445    #[tokio::test]
8446    async fn accepting_the_same_bundle_twice_is_idempotent() {
8447        // A bot restart or a duplicate invite delivery: accepting the SAME bundle
8448        // again must upsert cleanly — same community_id, no duplicate channels, no
8449        // corruption, the keys unchanged.
8450        let (bed, owner, member) = TestBed::new();
8451        bed.swap_to(&owner);
8452        let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
8453        create_public_channel(&bed.relay, &community, "extra").await.unwrap();
8454        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8455        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8456
8457        bed.swap_to(&member);
8458        let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8459        let channels_after_first = first.channels.len();
8460        let root_after_first = first.community_root;
8461
8462        // Accept the identical bundle again (restart / redelivery).
8463        let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8464        assert_eq!(second.id().0, first.id().0, "same community_id");
8465        assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
8466        assert_eq!(second.community_root, root_after_first, "root unchanged");
8467
8468        // The persisted state is a single clean community with the expected channels.
8469        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8470        assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
8471        assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
8472    }
8473
8474    #[tokio::test]
8475    async fn a_severed_member_can_be_unbanned_and_re_admitted() {
8476        // The full moderation HEAL lifecycle: ban (banlist + grant strip + refound)
8477        // severs a member; the owner then unbans + sends a FRESH invite carrying the
8478        // NEW root; the member rejoins at the new epoch and converses again. Proves
8479        // a ban is reversible end-to-end, not a one-way door.
8480        let (bed, owner, member) = TestBed::new();
8481        bed.swap_to(&owner);
8482        let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
8483        let general = community.channels[0].id;
8484        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8485        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8486
8487        bed.swap_to(&member);
8488        let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8489        let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
8490        assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
8491
8492        // Owner bans the member (CORD-04 §6 three-removal) → refound severs them.
8493        bed.swap_to(&owner);
8494        set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
8495        grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
8496        community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8497        assert_eq!(community.root_epoch, Epoch(1));
8498        send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
8499
8500        // The member's follow concludes severance (no blob at the new epoch).
8501        bed.swap_to(&member);
8502        let session = SessionGuard::capture();
8503        assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
8504
8505        // Owner unbans + re-invites: build the fresh epoch-1 bundle (accept it
8506        // directly, so the test picks the NEW invite unambiguously rather than an
8507        // arbitrary one of the two pending 3313s).
8508        bed.swap_to(&owner);
8509        set_banlist(&bed.relay, &community, &[]).await.unwrap();
8510        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8511        assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
8512        let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8513
8514        // Member accepts the fresh invite → rejoins at epoch 1, reads current + posts.
8515        bed.swap_to(&member);
8516        let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
8517        assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
8518        assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
8519        let seen = texts_in(&bed.relay, &rejoined, &general).await;
8520        assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
8521        send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
8522
8523        bed.swap_to(&owner);
8524        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8525        assert!(
8526            texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
8527            "the re-admitted member converses again at the new epoch"
8528        );
8529        // And they're back in the memberlist.
8530        let members = memberlist(&bed.relay, &community).await.unwrap();
8531        assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
8532    }
8533
8534    #[tokio::test]
8535    async fn dissolution_blocks_a_join() {
8536        // CORD-02 §9: the owner dissolves; a would-be joiner resolves the grave and
8537        // refuses to join.
8538        let (bed, owner, member) = TestBed::new();
8539        bed.swap_to(&owner);
8540        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8541        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8542        let bundle_json = serde_json::to_string(&bundle).unwrap();
8543        dissolve_community(&bed.relay, &community).await.unwrap();
8544        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
8545
8546        bed.swap_to(&member);
8547        let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
8548        assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
8549    }
8550
8551    #[tokio::test]
8552    async fn dissolution_seals_writes_but_not_reads() {
8553        // CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
8554        // the history stays browsable, and only explicit user intent deletes it.
8555        let (bed, owner, _member) = TestBed::new();
8556        bed.swap_to(&owner);
8557        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8558        let general = community.channels[0].id;
8559        send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
8560
8561        dissolve_community(&bed.relay, &community).await.unwrap();
8562        let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8563
8564        for err in [
8565            send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
8566            send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
8567                .await
8568                .unwrap_err(),
8569            send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
8570        ] {
8571            assert!(err.contains("dissolved"), "every write is refused, got: {err}");
8572        }
8573        assert!(
8574            texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
8575            "but the history still reads"
8576        );
8577    }
8578
8579    #[tokio::test]
8580    async fn only_the_owner_can_dissolve() {
8581        let (bed, owner, member) = TestBed::new();
8582        bed.swap_to(&owner);
8583        let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
8584        bed.swap_to(&member);
8585        assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
8586        assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
8587    }
8588
8589    #[tokio::test]
8590    async fn a_foreign_tombstone_is_not_death() {
8591        // A non-owner sealing the dissolved plane is noise (verify_dissolved is
8592        // owner-gated), so the community is not treated as dead.
8593        let (_tmp, _guard, _owner) = init_test_db();
8594        let relay = MemoryRelay::new();
8595        let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
8596        let rogue = Keys::generate();
8597        let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
8598        let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
8599        relay.publish(&wrap, &community.relays).await.unwrap();
8600        assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
8601    }
8602
8603    #[tokio::test]
8604    async fn a_public_channel_reads_history_across_a_refounding() {
8605        // CORD-03 §3: after a Refounding rolls the base root, a Public channel's
8606        // pre-rotation messages stay readable (the prior epoch's root is archived and
8607        // the read fans out across held epochs).
8608        let (_tmp, _guard, _owner) = init_test_db();
8609        let relay = MemoryRelay::new();
8610        let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
8611        let general = community.channels[0].id;
8612        send_message(&relay, &community, &general, "before the refounding").await.unwrap();
8613
8614        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
8615        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8616        send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
8617
8618        let texts = texts_in(&relay, &refounded, &general).await;
8619        assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
8620        assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
8621    }
8622
8623    #[tokio::test]
8624    async fn refounding_aborts_when_control_state_is_withheld() {
8625        // B1 coverage gate (CORD-06 §3): a relay serving none of the committed control
8626        // heads must ABORT the Refounding — never silently drop state (e.g. unban a
8627        // member at the new epoch a fresh joiner bootstraps).
8628        let (_tmp, _guard, owner) = init_test_db();
8629        let relay = MemoryRelay::new();
8630        let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
8631        publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
8632        let session = SessionGuard::capture();
8633        follow_control(&relay, &community, &session).await.unwrap(); // seed the banlist floor
8634
8635        // Re-point the held community to an EMPTY relay + save, so the Refounding (which
8636        // reloads fresh state) fetches none of the committed heads.
8637        let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8638        moved.relays = vec!["wss://empty".into()];
8639        crate::db::community::save_community_v2(&moved).unwrap();
8640
8641        let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
8642        assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
8643        assert_eq!(
8644            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8645            Epoch(0),
8646            "the epoch did NOT advance (zero published state)"
8647        );
8648    }
8649
8650    #[tokio::test]
8651    async fn refounding_rolls_the_root_and_severs_a_removed_member() {
8652        // CORD-06 §3: the owner re-founds, removing a member. The base root rolls, the
8653        // epoch advances, and the removed member's rekey-follow concludes they're cut.
8654        let (bed, owner, member) = TestBed::new();
8655        bed.swap_to(&owner);
8656        let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
8657        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8658        let bundle_json = serde_json::to_string(&bundle).unwrap();
8659        bed.swap_to(&member);
8660        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8661
8662        bed.swap_to(&owner);
8663        let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8664        assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8665        assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
8666        // The owner still reads the compacted control plane at the new epoch.
8667        assert_eq!(
8668            crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8669            Epoch(1),
8670            "the owner committed the new epoch"
8671        );
8672
8673        // The removed member, following rekeys, is severed (no blob in the rotation).
8674        // Guard captured AFTER the swap: it must belong to the ACTING account (the harness
8675        // swap now bumps the generation exactly like a production swap_session).
8676        bed.swap_to(&member);
8677        let session = SessionGuard::capture();
8678        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8679        assert!(follow.self_removed, "the removed member is cut by the re-founding");
8680    }
8681
8682    #[tokio::test]
8683    async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
8684        // CORD-06 §Authority: a Refounding requires BAN, not owner-identity. A
8685        // non-owner admin granted BAN CAN re-found (and every member follows it —
8686        // see the receive-side test), but the "strictly outrank every removed
8687        // target" rule still holds: they can't use it to evict the owner.
8688        let (bed, owner, member) = TestBed::new();
8689        bed.swap_to(&owner);
8690        let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
8691        let rid = "b0".repeat(32);
8692        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8693        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8694        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8695        let bundle_json = serde_json::to_string(&bundle).unwrap();
8696        bed.swap_to(&member);
8697        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8698        // Fold the roster so this member's own DB reflects their BAN grant (the
8699        // authority check reads the folded Roster, not the bundle).
8700        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8701        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8702        // Can't evict the owner (no one outranks the owner).
8703        assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
8704        // But CAN re-found removing a plain member they outrank (here, nobody).
8705        assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
8706    }
8707
8708    #[tokio::test]
8709    async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
8710        // A BAN-holding ADMIN (not the owner) re-founds, and every member must
8711        // follow it — owner-only receive silently strands members whose community
8712        // was refounded by an admin (CORD-06 §Authority: "a Refounding requires
8713        // BAN", checked against the folded Roster).
8714        let (bed, owner, me) = TestBed::new();
8715        let admin = Keys::generate();
8716        bed.swap_to(&owner);
8717        let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
8718        let rid = "b0".repeat(32);
8719        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8720        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8721
8722        // I (a plain member) join, then fold the roster so I know the admin holds BAN.
8723        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8724        let bundle_json = serde_json::to_string(&bundle).unwrap();
8725        bed.swap_to(&me);
8726        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8727        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8728        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8729
8730        // The admin re-founds keeping the owner + me — the owner must always be a
8731        // recipient of a non-owner Refounding.
8732        let new_root = [0xC7; 32];
8733        publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8734
8735        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8736            .expect("an authorized admin's Refounding is adopted");
8737        assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
8738        assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
8739    }
8740
8741    #[tokio::test]
8742    async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
8743        // CORD-05 §2: a link shared once keeps working across rotations, because
8744        // its bundle is re-posted behind the same URL. The Refounder can only
8745        // refresh the bundles they hold signer secrets for — their OWN — so
8746        // every other creator has to heal their links when they ADOPT the
8747        // rotation. Without that, an admin's links keep vending the superseded
8748        // root and drop new joiners onto a dead epoch, which is precisely the
8749        // stranding the stable-URL refresh exists to prevent.
8750        let (bed, owner, me) = TestBed::new();
8751        bed.swap_to(&owner);
8752        let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
8753        let rid = "b1".repeat(32);
8754        // CREATE_INVITE too: minting is offer-gated by the same bit readers use.
8755        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
8756        publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8757
8758        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8759        let bundle_json = serde_json::to_string(&bundle).unwrap();
8760        bed.swap_to(&me);
8761        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8762        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8763        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8764
8765        // I mint a link of my own at the CURRENT epoch.
8766        let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
8767        let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8768        assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
8769
8770        // The OWNER re-founds. Their refresh can't touch my bundle: only I hold
8771        // its signer secret.
8772        let new_root = [0xD4; 32];
8773        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8774
8775        let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8776            .expect("the owner's Refounding is adopted");
8777        assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
8778
8779        let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8780        assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
8781        assert_eq!(
8782            crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
8783            new_root,
8784            "and the new root behind the same URL",
8785        );
8786    }
8787
8788    #[tokio::test]
8789    async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8790        // Authority escalation: a BAN-admin can't use a Refounding to evict the
8791        // OWNER (no one outranks the owner). Excluding them makes the rotation
8792        // inadmissible — members fork-reject it rather than migrate to the coup.
8793        let (bed, owner, me) = TestBed::new();
8794        let admin = Keys::generate();
8795        bed.swap_to(&owner);
8796        let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8797        let rid = "b0".repeat(32);
8798        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8799        publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8800
8801        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8802        let bundle_json = serde_json::to_string(&bundle).unwrap();
8803        bed.swap_to(&me);
8804        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8805        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8806        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8807
8808        // The admin re-founds delivering to me but NOT the owner — a takeover.
8809        publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8810        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8811        assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8812    }
8813
8814    #[tokio::test]
8815    async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8816        // Authority escalation: two equal-rank BAN-admins — neither strictly
8817        // outranks the other, so one can't Refound the other out. Excluding a
8818        // peer makes the rotation inadmissible.
8819        let (bed, owner, me) = TestBed::new();
8820        let admin_a = Keys::generate();
8821        let admin_b = Keys::generate(); // the peer admin the rotation excludes.
8822        bed.swap_to(&owner);
8823        let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8824        let rid = "b0".repeat(32);
8825        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8826        // Both A and B hold the SAME role (same position 1) → peers.
8827        publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8828        publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8829
8830        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8831        let bundle_json = serde_json::to_string(&bundle).unwrap();
8832        bed.swap_to(&me);
8833        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8834        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8835        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8836
8837        // Admin A re-founds keeping the owner + me but EXCLUDING peer admin B.
8838        publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8839
8840        let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8841        assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8842    }
8843
8844    #[tokio::test]
8845    async fn a_retried_refounding_reuses_the_same_root() {
8846        // B1 idempotency: minting for the same (scope, epoch) twice yields the SAME
8847        // root, so a retried Refounding re-delivers one root — never a double-mint fork.
8848        let (_tmp, _guard, _owner) = init_test_db();
8849        let relay = MemoryRelay::new();
8850        let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8851        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8852        let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8853        let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8854        assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8855    }
8856
8857    #[tokio::test]
8858    async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8859        // CORD-04 §2 rank inversion. Minting at a position you outrank is
8860        // necessary but NOT sufficient: an edition replaces the entity, so a
8861        // gate that only reads the NEW position lets an admin at position 5
8862        // rewrite the position-1 role to position 9. Every check passes (9 is
8863        // beneath them), and the role that outranked them — plus everyone
8864        // holding it — is now beneath them.
8865        let (bed, owner, attacker) = TestBed::new();
8866        bed.swap_to(&owner);
8867        let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8868
8869        // A senior role at position 1, and a mid role at position 5 the attacker holds.
8870        let senior = "a1".repeat(32);
8871        let mid = "a5".repeat(32);
8872        publish_role(&bed.relay, &community, &owner.keys,
8873            &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8874        publish_role(&bed.relay, &community, &owner.keys,
8875            &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8876        publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8877
8878        // The attacker republishes the SENIOR role, dropping it beneath themselves.
8879        publish_role(&bed.relay, &community, &attacker.keys,
8880            &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8881
8882        let authority = fetch_authority(&bed.relay, &community).await;
8883        let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8884        assert_eq!(
8885            folded_senior.position, 1,
8886            "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8887        );
8888    }
8889
8890    #[tokio::test]
8891    async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8892        // CORD-04 §5. Armada's reader REQUIRES this on every non-owner control
8893        // edition (`citationOk`: "a non-owner action MUST cite its grant"), so
8894        // an uncited Vector admin's ban/role/channel edit was silently dropped
8895        // by every Armada client — only the owner's actions crossed. The
8896        // citation must name the actor's OWN grant coordinate, at the version
8897        // and edition hash the verifier can match against a grant it holds.
8898        let (bed, owner, admin) = TestBed::new();
8899        bed.swap_to(&owner);
8900        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8901        let rid = "c1".repeat(32);
8902        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8903        publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8904
8905        // The owner's own edition carries NO citation: their rank is the id.
8906        let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8907        edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8908        let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8909            .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8910            .max_by_key(|e| e.version).expect("the owner's metadata edition");
8911        assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8912
8913        // The admin JOINS and folds — the citation names the grant head their own
8914        // client has actually synced, so the fold must have persisted it.
8915        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8916        let bundle_json = serde_json::to_string(&bundle).unwrap();
8917        bed.swap_to(&admin);
8918        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8919        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8920        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8921        set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8922
8923        let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8924            .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8925            .expect("the admin's banlist edition");
8926        let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8927        assert_eq!(
8928            cite.entity_id,
8929            crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8930            "the citation must name the ACTOR'S OWN grant coordinate",
8931        );
8932        assert!(cite.version >= 1, "pinned to a real grant version");
8933    }
8934
8935    #[tokio::test]
8936    async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8937        // `cap_relays` is the truncate-on-read invariant everywhere else, and the
8938        // fold is a boundary like any other: MANAGE_METADATA makes an editor
8939        // authorized, not trusted. An oversize list costs every member a fan-out
8940        // per publish and the slowest of N per fetch — and Armada caps at 5, so
8941        // an uncapped fold also splits the two clients' operative sets.
8942        let (_tmp, _guard, _owner) = init_test_db();
8943        let relay = MemoryRelay::new();
8944        let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8945
8946        let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8947        let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8948        edit_community_metadata(&relay, &community, &meta).await.unwrap();
8949
8950        let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8951            .expect("the metadata edition is folded");
8952        assert_eq!(
8953            updated.relays.len(),
8954            crate::community::MAX_COMMUNITY_RELAYS,
8955            "a folded relay list must be truncated, never adopted whole",
8956        );
8957
8958        // …and the fold must SETTLE: comparing an oversize edition against the
8959        // capped working set would never be equal, so every later fold would
8960        // report a change and re-save forever.
8961        let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8962        assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8963    }
8964
8965    #[tokio::test]
8966    async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8967        // One Invite List spans every community, so "I hold links" must never be
8968        // read as "I hold links HERE". A member with links elsewhere adopting a
8969        // rotation would otherwise publish an empty Registry edition into this
8970        // community — a control-plane write and a version bump on a coordinate
8971        // they never owned, every rotation, forever.
8972        let (bed, owner, me) = TestBed::new();
8973        bed.swap_to(&owner);
8974        let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8975        let rid = "b2".repeat(32);
8976        publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8977        publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8978
8979        let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8980        let bundle_json = serde_json::to_string(&bundle).unwrap();
8981        bed.swap_to(&me);
8982        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8983        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8984        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8985
8986        // My only link lives in a DIFFERENT community — one I own, since minting
8987        // is offer-gated on CREATE_INVITE.
8988        let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8989        mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8990
8991        let before = bed.relay.stored_count();
8992        let new_root = [0xE1; 32];
8993        publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8994        let rotation_events = bed.relay.stored_count() - before;
8995
8996        let after_adopt = bed.relay.stored_count();
8997        follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8998        assert_eq!(
8999            bed.relay.stored_count(),
9000            after_adopt,
9001            "adopting a rotation must publish NOTHING when I minted no links here",
9002        );
9003        assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
9004    }
9005
9006    #[tokio::test]
9007    async fn an_expired_link_stops_keeping_the_community_public() {
9008        // CORD-05 §1/§5: expiry is the one way a link dies with no user action.
9009        // A joiner is refused by `InviteBundle::expired`, so leaving the link in
9010        // the Registry states a door that isn't there — the aggregate never
9011        // empties and the community reads Public forever, silently inverting
9012        // every gate that hangs off that reading.
9013        let (_tmp, _guard, _owner) = init_test_db();
9014        let relay = MemoryRelay::new();
9015        let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
9016
9017        // A link that lapsed a minute ago.
9018        let past = now_ms() - 60_000;
9019        mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
9020        assert!(
9021            !community_is_public(&relay, &community).await,
9022            "an already-expired link must never read as a live door",
9023        );
9024
9025        // …and one that hasn't, to prove the filter isn't just dropping everything.
9026        mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
9027        assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
9028    }
9029
9030    #[tokio::test]
9031    async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
9032        // CORD-05 §5: the Registry is the Public/Private source of truth. Minting a
9033        // link publishes it (Public); retiring the last link empties it (Private).
9034        let (_tmp, _guard, _owner) = init_test_db();
9035        let relay = MemoryRelay::new();
9036        let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
9037        assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
9038
9039        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9040        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
9041        let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
9042        assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
9043
9044        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
9045        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
9046        assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
9047        let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
9048        assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
9049    }
9050
9051    #[tokio::test]
9052    async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
9053        // Every caller reads the `invite_registry` COLUMN, never the async fold. v2
9054        // published the Registry to the plane but never mirrored it locally, so every
9055        // v2 community read Private no matter how many live links it had.
9056        let (_tmp, _guard, _owner) = init_test_db();
9057        let relay = MemoryRelay::new();
9058        let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
9059        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9060        let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
9061        // The per-creator split is a SEPARATE table, and it drives the "first link flips
9062        // the community Public" confirm — an empty one re-asks on every later link.
9063        let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
9064        assert!(cached().is_empty(), "a fresh community caches an empty registry");
9065        assert!(per_creator().is_empty(), "…and no per-creator sets");
9066
9067        let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9068        assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
9069        let sets = per_creator();
9070        assert_eq!(sets.len(), 1, "the minting creator gets a set");
9071        assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
9072
9073        // Both caches must SHRINK too — a union-only mirror would strand it Public.
9074        let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
9075        revoke_public_link(&relay, &community, &token_hex).await.unwrap();
9076        assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
9077        assert!(per_creator().is_empty(), "…and clears the per-creator sets");
9078    }
9079
9080    #[tokio::test]
9081    async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
9082        // Registries are coordinate-bound to their creator, but `fold_head` picks an
9083        // equal-version winner AUTHOR-BLIND, by lowest inner id — and an author grinds
9084        // that freely by varying content. Folding before authorising would let any
9085        // member occupy the owner's registry head, fail the authority check, and drop
9086        // the whole registry: a live invite link silently retired, flipping the
9087        // community to Private and steering a moderator into the wrong ban remedy.
9088        let (_tmp, _guard, owner) = init_test_db();
9089        let relay = MemoryRelay::new();
9090        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
9091        mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9092        assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
9093
9094        let cid = community.id();
9095        let control = control_group_key(&community.community_root, cid, community.root_epoch);
9096        let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
9097
9098        let query = Query {
9099            kinds: vec![stream::KIND_WRAP],
9100            authors: vec![control.pk_hex()],
9101            limit: Some(FOLLOW_PAGE),
9102            ..Default::default()
9103        };
9104        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9105        let target = wraps
9106            .iter()
9107            .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
9108            .filter(|e| e.entity_id == eid)
9109            .max_by_key(|e| e.version)
9110            .expect("the owner published a registry");
9111
9112        // Grind a same-version fork under the owner's coordinate that OUTRANKS the
9113        // real head on the tiebreak (~2 tries against a uniform id).
9114        let rogue = Keys::generate();
9115        let mut planted = false;
9116        for n in 0..4_000u64 {
9117            let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
9118            let rumor = control::build_edition_rumor(
9119                rogue.public_key(),
9120                vsk::INVITE_LINKS,
9121                &eid,
9122                target.version,
9123                target.prev_hash.as_ref(),
9124                &content,
9125                9_000,
9126                None,
9127            );
9128            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
9129            let (ed, _) = control::open_control_edition(&w, &control).unwrap();
9130            if ed.inner_id < target.inner_id {
9131                relay.publish(&w, &community.relays).await.unwrap();
9132                planted = true;
9133                break;
9134            }
9135        }
9136        assert!(planted, "the test needs a fork that wins the tiebreak");
9137
9138        assert!(
9139            community_is_public(&relay, &community).await,
9140            "an unauthorised fork must not retire the owner's live link"
9141        );
9142    }
9143
9144    #[tokio::test]
9145    async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
9146        // The CREATE_INVITE gate: a rogue publishing a registry can't fake Public.
9147        let (_tmp, _guard, owner) = init_test_db();
9148        let relay = MemoryRelay::new();
9149        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
9150        let rogue = Keys::generate();
9151        // Rogue publishes a registry edition at THEIR coordinate with a fake signer.
9152        let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
9153        let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
9154        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9155        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
9156        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
9157        relay.publish(&wrap, &community.relays).await.unwrap();
9158        let _ = owner;
9159        assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
9160    }
9161
9162    #[tokio::test]
9163    async fn full_lifecycle_e2e() {
9164        // The whole stack end to end across two accounts: create -> Public link ->
9165        // owner grants an admin -> member joins + reads history -> admin edits metadata
9166        // (authorized fold) -> owner bans the member (CORD-04 §6: banlist + strip +
9167        // Refounding) -> the banned member is severed AND stays banned across the new
9168        // epoch -> pre-ban history still reads -> owner dissolves -> sealed.
9169        let (bed, owner, member) = TestBed::new();
9170
9171        bed.swap_to(&owner);
9172        let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
9173        let general = community.channels[0].id;
9174        send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
9175
9176        // Public link → the community reads Public.
9177        let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
9178        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9179
9180        // Owner defines + grants an Admin role (MANAGE_METADATA among the bits).
9181        let rid = "aa".repeat(32);
9182        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9183        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
9184
9185        // Member joins from the bundle + reads the owner's message.
9186        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9187        let bundle_json = serde_json::to_string(&bundle).unwrap();
9188        bed.swap_to(&member);
9189        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9190        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
9191        // The admin renames the community.
9192        publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
9193
9194        // Owner follows: the admin's rename folds (authorized).
9195        bed.swap_to(&owner);
9196        let session = SessionGuard::capture();
9197        let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
9198        assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
9199
9200        // Ban the member (the three-removal composition, in order).
9201        set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
9202        grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
9203        let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
9204        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9205        // The ban survives the Refounding (the banlist head compacted forward).
9206        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9207        assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
9208        // Pre-ban history still reads across the new epoch.
9209        assert!(
9210            texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
9211            "pre-refounding history stays readable"
9212        );
9213
9214        // The banned member's rekey-follow concludes they're severed. Guard captured AFTER
9215        // the swap (the harness swap bumps the generation like production).
9216        bed.swap_to(&member);
9217        let session = SessionGuard::capture();
9218        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9219        assert!(follow.self_removed, "the banned member is cryptographically cut");
9220
9221        // Owner dissolves → sealed.
9222        bed.swap_to(&owner);
9223        dissolve_community(&bed.relay, &refounded).await.unwrap();
9224        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9225    }
9226
9227    /// The deep two-account e2e the way a real deployment runs: owner (A) + member (B)
9228    /// over one shared relay, create → channels (public + private) → converse both ways →
9229    /// persist (get_messages-level) → react/edit/delete → moderate (ban/unban) → dissolve.
9230    /// Every account, community, channel, and action is LOGGED (run with --nocapture) so it
9231    /// doubles as a reference transcript and a re-runnable regression.
9232    #[tokio::test]
9233    async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
9234        // A member forges a higher-version role edition at the admin coordinate before a
9235        // refounding. The compaction must carry the AUTHORIZED floor head, not the
9236        // author-blind version tip — else the forgery is re-anchored, honest folders drop
9237        // it, and the admin role vanishes at the new epoch (silent suppression).
9238        let (bed, owner, member) = TestBed::new();
9239        let attacker = Keys::generate();
9240        bed.swap_to(&owner);
9241        let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
9242        let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9243        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9244        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
9245        // Owner folds → the authorized role/grant heads are floored.
9246        let session = SessionGuard::capture();
9247        follow_control(&bed.relay, &community, &session).await.unwrap();
9248        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
9249
9250        // The attacker (a non-owner) forges v2 of the admin role, chaining onto v1.
9251        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;
9252
9253        // Owner refounds (keeping everyone).
9254        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9255        assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
9256
9257        // Post-refound, the admin role SURVIVES (the authorized floor head was carried).
9258        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9259        assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
9260    }
9261
9262    #[tokio::test]
9263    async fn memberlist_survives_a_refounding_via_the_snapshot() {
9264        // A silent survivor (didn't re-post at the new epoch) must stay in the memberlist
9265        // after a refounding — the owner's 3312 snapshot re-seeds them (CORD-02 §5).
9266        let (bed, owner, member) = TestBed::new();
9267        bed.swap_to(&owner);
9268        let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
9269
9270        // Member joins (a Guestbook Join at epoch 0).
9271        let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
9272        bed.swap_to(&member);
9273        accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
9274        bed.swap_to(&owner);
9275        assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
9276
9277        // Owner refounds keeping everyone (removed = []); survivors are snapshotted to epoch 1.
9278        let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9279        assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
9280
9281        // The member is STILL a member at epoch 1 purely via the snapshot (never re-posted).
9282        let members = memberlist(&bed.relay, &refounded).await.unwrap();
9283        assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
9284        assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
9285    }
9286
9287    #[tokio::test]
9288    async fn e2e_two_accounts_channels_converse_moderate() {
9289        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
9290        use nostr_sdk::prelude::ToBech32;
9291        let (bed, a, b) = TestBed::new();
9292        let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
9293        let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
9294        println!("\n===== Concord v2 deep e2e =====");
9295        println!("[acct] A (owner)  = {a_npub}");
9296        println!("[acct] B (member) = {b_npub}");
9297
9298        // ── A creates the community + a PRIVATE channel + two extra PUBLIC channels ──
9299        bed.swap_to(&a);
9300        let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
9301        let general = community.channels[0].id;
9302        println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
9303
9304        // A PRIVATE channel via the REAL create path: an independent key minted at
9305        // channel-epoch 1, delivered over the rekey plane (A is the only member yet),
9306        // then announced (vsk 2) — later carried to B in the join bundle.
9307        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9308        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9309        let priv_ch = community.channel(&priv_id).unwrap();
9310        assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
9311        println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
9312
9313        // Two more PUBLIC channels via the real create path.
9314        let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
9315        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9316        let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
9317        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9318        println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
9319        assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
9320
9321        // A talks in a few channels.
9322        let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
9323        send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
9324        send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
9325        println!("[msg] A posted in #general / #announcements / #mods");
9326
9327        // ── A grants B admin, mints a public link, B joins from the bundle ──
9328        let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9329        publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
9330        publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
9331        let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
9332        assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9333        println!("[invite] granted B @admin · minted link {}", link.url);
9334
9335        // A private channel is readable only by granted role-holders (CORD-03), so
9336        // B is added to its access list before the bundle is minted.
9337        grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
9338        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
9339        bed.swap_to(&b);
9340        let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9341        println!("[join] B joined; sees {} channels", b_view.channels.len());
9342        assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
9343        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");
9344        assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
9345        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");
9346        // B folds the control plane (persisting the roster) — the live worker does
9347        // this right after any join; B's admin standing gates B's channel ops below.
9348        let session_b = SessionGuard::capture();
9349        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
9350            b_view = fresh;
9351        }
9352        println!("[follow] B folded control (roster persisted: B is @admin)");
9353
9354        // ── Conversation both ways + persistence (get_messages-level) ──
9355        send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
9356        send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
9357        println!("[msg] B replied in #general + #mods");
9358        // Persist B's own #general view into the shared store (what sync/live ingest does)
9359        // and confirm it reads back via STATE — get_messages parity.
9360        let my_pk = b.keys.public_key();
9361        let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
9362        for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
9363            let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
9364            if let Some(o) = outcome { persist_chat(&gh, &o).await; }
9365        }
9366        assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
9367        println!("[persist] #general history persisted into the shared events store");
9368
9369        // B (admin) reacts to + the author edits/deletes — the chat-op surface.
9370        send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
9371        bed.swap_to(&a);
9372        let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
9373        send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
9374        let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
9375        send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
9376        println!("[ops] reaction + edit + delete round-tripped");
9377
9378        // ── B creates a channel as admin, A folds it in ──
9379        bed.swap_to(&b);
9380        let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
9381        println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
9382        bed.swap_to(&a);
9383        let session = SessionGuard::capture();
9384        if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
9385            community = updated;
9386        }
9387        assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
9388        println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
9389
9390        // ── A creates a SECOND private channel while B is already a member. B is
9391        // NOT on its access list, so B learns the channel exists (control-follow,
9392        // keyless) and gets no key: CORD-03's private channel is readable only by
9393        // granted role-holders, never by every member. B keys up if and when A
9394        // grants them the channel's access role and vends the key ──
9395        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9396        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9397        send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
9398        println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
9399        bed.swap_to(&b);
9400        let session_b2 = SessionGuard::capture();
9401        if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
9402            b_view = fresh;
9403        }
9404        let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
9405        assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
9406        let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
9407        if let Some(fresh) = rf.updated {
9408            b_view = fresh;
9409        }
9410        let ch = b_view.channel(&vault).expect("still recorded");
9411        assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
9412        assert!(
9413            texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
9414            "and reads nothing from it"
9415        );
9416        assert!(
9417            send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
9418            "an unentitled member cannot post into the channel either"
9419        );
9420        bed.swap_to(&a);
9421        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9422        println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
9423
9424        // ── Members ──
9425        let members = memberlist(&bed.relay, &community).await.unwrap();
9426        let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
9427        assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
9428        println!("[members] {} members: A + B present", members.len());
9429
9430        // ── Moderate: ban B (banlist + strip + refound), verify severance + survival ──
9431        set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
9432        grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
9433        let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
9434        assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9435        let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9436        assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
9437        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");
9438        assert!(
9439            texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
9440            "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
9441        );
9442        println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
9443        // B concludes it's severed.
9444        bed.swap_to(&b);
9445        let session_b3 = SessionGuard::capture();
9446        assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
9447        println!("[ban] B's rekey-follow: self_removed = true (severed)");
9448
9449        // ── Unban: A lifts the ban ──
9450        bed.swap_to(&a);
9451        set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
9452        let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9453        assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
9454        println!("[unban] B removed from the banlist (re-invitable)");
9455
9456        // ── Dissolve ──
9457        dissolve_community(&bed.relay, &refounded).await.unwrap();
9458        assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9459        println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
9460    }
9461
9462    /// The same scenario on a REAL relay with TWO throwaway accounts, off by default. It
9463    /// LOGS both nsecs (+ every id) so you can inspect the run and RE-RUN against the same
9464    /// accounts by exporting `VECTOR_E2E_NSEC_A` / `_B`. Set `VECTOR_E2E_LOG=<path>` to also
9465    /// append the transcript to a file, `VECTOR_E2E_RELAY=<url>` to pick the relay.
9466    ///   cargo test -p vector-core -- --ignored --nocapture live_e2e_two_accounts
9467    #[tokio::test]
9468    #[ignore]
9469    async fn live_e2e_two_accounts() {
9470        use crate::community::transport::LiveTransport;
9471        use nostr_sdk::prelude::ToBech32;
9472
9473        let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
9474        let relays = vec![relay.clone()];
9475        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9476        crate::db::close_database();
9477        crate::db::clear_id_caches();
9478        let tmp = tempfile::tempdir().unwrap();
9479        crate::db::set_app_data_dir(tmp.path().to_path_buf());
9480
9481        // Throwaway (or bring-your-own via env for a re-run against the same accounts).
9482        let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9483        let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9484
9485        let log = |line: String| {
9486            println!("{line}");
9487            if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
9488                use std::io::Write;
9489                if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
9490                    let _ = writeln!(f, "{line}");
9491                }
9492            }
9493        };
9494        log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
9495        log(format!("VECTOR_E2E_NSEC_A={}  ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
9496        log(format!("VECTOR_E2E_NSEC_B={}  ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
9497
9498        for k in [&a, &b] {
9499            let npub = k.public_key().to_bech32().unwrap();
9500            std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
9501            crate::db::set_current_account(npub.clone()).unwrap();
9502            crate::db::init_database(&npub).unwrap();
9503        }
9504        // One relay connection: a v2 wrap is pre-signed (ephemeral p-key) and its seal is
9505        // signed by MY_SECRET_KEY, so publishing needs no per-account client signer.
9506        let client = crate::nostr_client_builder().build();
9507        client.add_managed_relay(relay.as_str()).await.ok();
9508        client.connect().await;
9509        crate::state::set_nostr_client(client);
9510        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
9511        let become_acct = |k: &Keys| {
9512            let npub = k.public_key().to_bech32().unwrap();
9513            crate::db::set_current_account(npub.clone()).unwrap();
9514            crate::db::init_database(&npub).unwrap();
9515            crate::db::clear_id_caches();
9516            crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
9517            crate::state::set_my_public_key(k.public_key());
9518        };
9519        let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
9520
9521        // A: create + a channel + grant B admin + mint link.
9522        become_acct(&a);
9523        let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
9524        let general = community.channels[0].id;
9525        log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
9526        send_message(&transport, &community, &general, "A: live hello").await.expect("send");
9527        let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
9528        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9529        log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
9530        grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
9531        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
9532        log(format!("[invite] B granted @admin · link {}", link.url));
9533        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
9534        settle().await;
9535
9536        // B: join + read A's history + reply.
9537        become_acct(&b);
9538        let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
9539        log(format!("[join] B joined; {} channels", b_view.channels.len()));
9540        settle().await;
9541        let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
9542        let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9543        log(format!("[read] B sees #general: {seen:?}"));
9544        assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
9545        send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
9546
9547        // B posts a NIP-22 kind-1111 THREADED REPLY to A's message (the shape Armada
9548        // sends) directly onto the chat plane — proving the cross-client thread
9549        // RECEIVE path works live, not just in the offline fixture.
9550        let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
9551        let hello_id = hello.event.opened().rumor_id.to_hex();
9552        let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
9553        let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
9554        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());
9555        let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
9556        transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
9557        log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
9558        settle().await;
9559
9560        // A reads the thread reply back, rendered inline with A's message as parent.
9561        become_acct(&a);
9562        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9563        let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
9564        let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
9565        if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
9566            assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
9567            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");
9568        } else {
9569            panic!("the 1111 parsed as a Message");
9570        }
9571        log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
9572        become_acct(&b);
9573        settle().await;
9574
9575        // A: create a PRIVATE channel while B is already a member — B is a recipient
9576        // of the creation delivery, so B keys up from the rekey plane over the real
9577        // relay (no bundle involved), then the two converse on it.
9578        become_acct(&a);
9579        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9580        let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
9581        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9582        send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
9583        log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
9584        settle().await;
9585
9586        become_acct(&b);
9587        let session_b = SessionGuard::capture();
9588        let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
9589        if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
9590            b_view = fresh;
9591        }
9592        if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
9593            b_view = fresh;
9594        }
9595        let vch = b_view.channel(&vault).expect("B folded the vault");
9596        assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
9597        let vseen = texts_in(&transport, &b_view, &vault).await;
9598        log(format!("[read] B sees #vault: {vseen:?}"));
9599        assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
9600        send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
9601        settle().await;
9602
9603        become_acct(&a);
9604        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9605        assert!(
9606            texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
9607            "A reads B's private reply"
9608        );
9609        log("[private] two-way #vault conversation over the live relay".to_string());
9610
9611        // A: ban B (three-removal) + dissolve.
9612        set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
9613        grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
9614        let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
9615        log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
9616        settle().await;
9617        dissolve_community(&transport, &refounded).await.expect("dissolve");
9618        log("[dissolve] community sealed".to_string());
9619        log("===== LIVE e2e PASS =====".to_string());
9620    }
9621
9622    #[tokio::test]
9623    async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
9624        // The tombstone rides its own public plane, watched live — an OFFLINE
9625        // member's catch-up must fetch it too, or they follow (and post into) a
9626        // grave forever.
9627        let (bed, owner, member) = TestBed::new();
9628        bed.swap_to(&owner);
9629        let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
9630        let general = community.channels[0].id;
9631        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9632
9633        bed.swap_to(&member);
9634        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9635        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9636
9637        // The owner dissolves while the member sleeps.
9638        bed.swap_to(&owner);
9639        dissolve_community(&bed.relay, &community).await.unwrap();
9640
9641        // The member's catch-up learns of the death, seals, and refuses to post.
9642        bed.swap_to(&member);
9643        let session = SessionGuard::capture();
9644        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9645        assert!(follow.dissolved, "the catch-up surfaces the tombstone");
9646        assert!(!follow.self_removed && follow.updated.is_none());
9647        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9648        assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
9649        let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
9650        assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
9651        // Subsequent follows take the local fast path — still dissolved, no churn.
9652        let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9653        assert!(again.dissolved && again.updated.is_none());
9654    }
9655
9656    #[tokio::test]
9657    async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
9658        // Scale stress: MANY private channels, each rotated on every Refounding.
9659        // A member offline across two refoundings must converge on all of them
9660        // (the per-channel rotation fan in refound + the follow's channel×root×step
9661        // loops stay bounded) with every channel's history readable.
9662        const PRIV_CHANNELS: usize = 6;
9663        let (bed, owner, member) = TestBed::new();
9664        bed.swap_to(&owner);
9665        let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
9666        let mut priv_ids = Vec::new();
9667        for i in 0..PRIV_CHANNELS {
9668            let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
9669            community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9670            send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
9671            priv_ids.push(id);
9672        }
9673        // Private channels are readable only by granted role-holders (CORD-03).
9674        for id in &priv_ids {
9675            grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
9676        }
9677        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9678
9679        // Member joins at epoch 0 with all channel keys, then goes offline.
9680        bed.swap_to(&member);
9681        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9682        assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
9683
9684        // Two refoundings (each rotates the base + every private channel).
9685        bed.swap_to(&owner);
9686        for epoch in 1..=2u64 {
9687            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9688            assert_eq!(community.root_epoch, Epoch(epoch));
9689            for id in &priv_ids {
9690                send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
9691            }
9692        }
9693
9694        // Member returns: bounded follow to quiescence.
9695        bed.swap_to(&member);
9696        let session = SessionGuard::capture();
9697        let mut passes = 0;
9698        loop {
9699            passes += 1;
9700            assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
9701            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9702            let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9703            assert!(!rk.self_removed);
9704            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9705            let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
9706            if rk.updated.is_none() && ctl.is_none() {
9707                break;
9708            }
9709        }
9710        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9711        assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
9712        // Every private channel converged to the owner's current key + reads all epochs.
9713        for id in &priv_ids {
9714            let mine = caught_up.channel(id).expect("channel survived");
9715            let theirs = community.channel(id).unwrap();
9716            assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
9717            assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
9718            let texts = texts_in(&bed.relay, &caught_up, id).await;
9719            let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
9720            assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
9721            for epoch in 1..=2u64 {
9722                assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
9723            }
9724        }
9725    }
9726
9727    #[tokio::test]
9728    async fn an_offline_member_catches_up_across_three_refoundings() {
9729        // The deep offline-online scenario: a member sleeps through THREE
9730        // Refoundings, per-refound private-channel rotations, a mid-life private
9731        // channel CREATED while they slept, a public channel, a rename, and a
9732        // ban — then returns and converges by follow alone (no rejoin).
9733        use nostr_sdk::prelude::ToBech32;
9734        let (bed, owner, member) = TestBed::new();
9735        bed.swap_to(&owner);
9736        let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
9737        let general = community.channels[0].id;
9738        let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9739        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9740        send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
9741        send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
9742        // Private channels are readable only by granted role-holders (CORD-03).
9743        grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
9744        let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9745
9746        // Member joins at epoch 0, then goes OFFLINE.
9747        bed.swap_to(&member);
9748        let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9749        assert_eq!(member_view.root_epoch, Epoch(0));
9750
9751        // While they sleep, the owner reshapes everything across three epochs.
9752        bed.swap_to(&owner);
9753        let stranger = Keys::generate();
9754        for epoch in 1..=3u64 {
9755            community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9756            assert_eq!(community.root_epoch, Epoch(epoch));
9757            send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
9758            send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
9759        }
9760        let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
9761        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9762        let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9763        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9764        // The sleeper is on this channel's access list, so the refoundings that
9765        // follow deliver its key to them (CORD-03).
9766        grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
9767        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9768        send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
9769        set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
9770        let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
9771        edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
9772
9773        // The member RETURNS: rekey+control follow to quiescence (the worker's
9774        // loop, driven explicitly). Bounded — convergence must be fast.
9775        bed.swap_to(&member);
9776        let session = SessionGuard::capture();
9777        let mut passes = 0;
9778        loop {
9779            passes += 1;
9780            assert!(passes <= 6, "catch-up must converge, not churn");
9781            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9782            let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9783            assert!(!rekeyed.self_removed, "the member was never removed");
9784            let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9785            let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9786            if rekeyed.updated.is_none() && controlled.is_none() {
9787                break;
9788            }
9789        }
9790        let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9791
9792        // Base + name converged.
9793        assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9794        assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9795        assert_eq!(caught_up.name, "Sleeper Reborn");
9796        // Channels: renamed set incl. the mid-sleep public + private ones.
9797        assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9798        let m = caught_up.channel(&mods).expect("mods survived");
9799        let owner_mods = community.channel(&mods).unwrap();
9800        assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9801        assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9802        let v = caught_up.channel(&vault).expect("vault folded in");
9803        // The sleeper is on vault's access list, but it was created AFTER the last
9804        // refounding — no rotation followed the grant, so no blob was ever
9805        // addressed to them. They hold the channel keyless until the grant's own
9806        // key vend lands (CORD-05 §6), which is what a rekey-only walk cannot do.
9807        assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9808        // Banlist survived the compactions.
9809        let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9810        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9811        assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9812        // History reads across EVERY epoch (public via base-root archive, private
9813        // via the per-channel archive built during the walk).
9814        let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9815        for epoch in 0..=3u64 {
9816            let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9817            assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9818        }
9819        let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9820        for epoch in 0..=3u64 {
9821            let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9822            assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9823        }
9824        // Keyless (above) means unreadable — a rekey walk cannot substitute for the
9825        // key vend that a grant carries.
9826        assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9827        // And the member can still speak.
9828        send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9829        bed.swap_to(&owner);
9830        assert!(
9831            texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9832            "the caught-up member converses at the new epoch ({})",
9833            member.keys.public_key().to_bech32().unwrap()
9834        );
9835    }
9836
9837    /// Seal `n` messages onto a community's #general, one per second starting at
9838    /// `base_secs` (distinct wrap seconds so relay-side `until` paging engages).
9839    async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9840        let general = community.channels[0].id;
9841        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9842        for i in 0..n {
9843            let at = base_secs + i as u64;
9844            let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9845            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9846            relay.publish(&wrap, &community.relays).await.unwrap();
9847        }
9848    }
9849
9850    #[tokio::test]
9851    async fn the_history_walk_pages_past_a_multi_page_burst() {
9852        // A bot offline through 120 messages must catch ALL of them, not the
9853        // newest page — the v1 sync-gap class, closed by until-paging.
9854        let (_tmp, _guard, owner) = init_test_db();
9855        let relay = MemoryRelay::new();
9856        let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9857        let general = community.channels[0].id;
9858        flood_general(&relay, &community, &owner, 120, 10_000).await;
9859
9860        let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9861        assert_eq!(all.len(), 120, "the walk pages the whole burst");
9862        // Oldest→newest, no duplicates.
9863        let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9864        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9865        assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9866        let unique: std::collections::HashSet<&String> = contents.iter().collect();
9867        assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9868
9869        // The single-page fetch stays a single page.
9870        let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9871        assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9872        assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9873    }
9874
9875    #[tokio::test]
9876    async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
9877        // The back-paging cursor: a walk that starts at an explicit `until`
9878        // returns only what lies at-or-before it, oldest→newest — the relay-side
9879        // half of the SDK's walk-until-dry loop.
9880        let (_tmp, _guard, owner) = init_test_db();
9881        let relay = MemoryRelay::new();
9882        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9883        let general = community.channels[0].id;
9884        flood_general(&relay, &community, &owner, 120, 10_000).await;
9885
9886        let older = fetch_channel_history(
9887            &relay, &community, &general, 50, 8, None, Some(10_059),
9888            crate::community::transport::Evidence::Quorum, |_| true,
9889        )
9890        .await
9891        .unwrap();
9892        let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9893        assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
9894        assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9895        assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
9896    }
9897
9898    #[tokio::test]
9899    async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9900        let (_tmp, _guard, owner) = init_test_db();
9901        let relay = MemoryRelay::new();
9902        let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9903        let general = community.channels[0].id;
9904        flood_general(&relay, &community, &owner, 120, 10_000).await;
9905
9906        // The caller says "I hold everything" after the first page — no deeper fetch.
9907        let mut pages = 0usize;
9908        let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
9909            pages += 1;
9910            false
9911        })
9912        .await
9913        .unwrap();
9914        assert_eq!(pages, 1, "the early stop is consulted once");
9915        assert_eq!(got.len(), 50, "only the newest page is fetched");
9916        assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9917    }
9918
9919    #[tokio::test]
9920    async fn a_same_second_history_wall_terminates_instead_of_looping() {
9921        // 60 messages in ONE second with a 25-wrap page: a second-granular
9922        // `until` can never page past the wall — the walk must step over it
9923        // (bounded loss, logged) rather than spin.
9924        let (_tmp, _guard, owner) = init_test_db();
9925        let relay = MemoryRelay::new();
9926        let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9927        let general = community.channels[0].id;
9928        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9929        for i in 0..60usize {
9930            let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9931            let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9932            relay.publish(&wrap, &community.relays).await.unwrap();
9933        }
9934        let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9935        assert!(got.len() >= 25, "at least the relay page is read");
9936        assert!(got.len() <= 60, "sane bound");
9937        // Termination is the assertion: reaching here means the wall didn't loop.
9938    }
9939
9940    #[tokio::test]
9941    async fn a_grant_revoke_survives_a_withholding_relay() {
9942        // Floor persistence on the delegation plane: after the owner revokes an admin,
9943        // a relay serving only the OLD (still owner-signed) grant can't resurrect it.
9944        let (_tmp, _guard, owner) = init_test_db();
9945        let relay = MemoryRelay::new();
9946        let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9947        let admin = Keys::generate();
9948        let rid = "d4".repeat(32);
9949        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9950        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9951        let session = SessionGuard::capture();
9952        follow_control(&relay, &community, &session).await.unwrap(); // seed floors incl. the grant at v1
9953        publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; // revoke → grant floor v2
9954        follow_control(&relay, &community, &session).await.unwrap();
9955
9956        // A stale relay serves only the grant prefix (v1, the live grant).
9957        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9958        let mut stale = community.clone();
9959        stale.relays = vec!["wss://stale".into()];
9960        let floors = load_floors(&community);
9961        let editions = fetch_control(&relay, &stale).await;
9962        let authority = fold_authority(&stale, &editions, &floors);
9963        assert!(
9964            !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9965            "the persisted grant floor refuses the rolled-back (re-granted) view"
9966        );
9967    }
9968
9969    /// Load the current-epoch floors for a community (test mirror of follow_control).
9970    fn load_floors(community: &CommunityV2) -> Floors {
9971        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9972        crate::db::community::get_all_edition_heads_full(&cid_hex)
9973            .unwrap_or_default()
9974            .into_iter()
9975            .filter(|(_, f)| f.0 == community.root_epoch.0)
9976            .map(|(e, f)| (e, (f.1, f.2, f.3)))
9977            .collect()
9978    }
9979
9980    /// Fetch + open every control edition at a community's control plane (test helper).
9981    async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9982        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9983        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9984        relay
9985            .fetch(&q, &community.relays)
9986            .await
9987            .unwrap_or_default()
9988            .iter()
9989            .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9990            .collect()
9991    }
9992
9993    #[tokio::test]
9994    async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9995        let (_tmp, _guard, _owner) = init_test_db();
9996        let relay = MemoryRelay::new();
9997        let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9998        let session = SessionGuard::capture();
9999        // Only the genesis editions exist; folding them reproduces the held view.
10000        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
10001    }
10002
10003    #[tokio::test]
10004    async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
10005        let (_tmp, _guard, owner) = init_test_db();
10006        let relay = MemoryRelay::new();
10007        let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
10008        let new_id = ChannelId([0x5a; 32]);
10009        publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
10010
10011        let session = SessionGuard::capture();
10012        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
10013        assert_eq!(updated.channels.len(), 2);
10014        let added = updated.channel(&new_id).expect("the new channel folded in");
10015        assert_eq!(added.name, "announcements");
10016        assert!(!added.private);
10017        assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
10018
10019        // The new channel is now in the realtime author-set (it would be subscribed).
10020        let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10021        let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
10022        assert!(authors.contains(&addr), "the added channel joins the live subscription");
10023
10024        // Persisted: a reload sees it too.
10025        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10026        assert!(reloaded.channel(&new_id).is_some());
10027    }
10028
10029    #[tokio::test]
10030    async fn follow_control_renames_the_community_and_an_existing_channel() {
10031        let (_tmp, _guard, owner) = init_test_db();
10032        let relay = MemoryRelay::new();
10033        let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
10034        let general = community.channels[0].id;
10035        // A v2 metadata edition renames the community; a v2 channel edition renames #general.
10036        publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
10037        publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
10038
10039        let session = SessionGuard::capture();
10040        let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
10041        assert_eq!(updated.name, "New Name");
10042        assert_eq!(updated.channel(&general).unwrap().name, "lobby");
10043        assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
10044    }
10045
10046    #[tokio::test]
10047    async fn follow_control_deletes_a_channel() {
10048        let (_tmp, _guard, owner) = init_test_db();
10049        let relay = MemoryRelay::new();
10050        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
10051        let extra = ChannelId([0x77; 32]);
10052        let session = SessionGuard::capture();
10053
10054        // The channel is first added and folded into the held view.
10055        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
10056        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
10057        assert!(with_extra.channel(&extra).is_some());
10058
10059        // Then it's tombstoned — the delete (higher version) folds the held one back out.
10060        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
10061        let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
10062        assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
10063        assert_eq!(updated.channels.len(), 1, "only #general remains");
10064    }
10065
10066    /// Re-inject only the OLD prefix (every edition at/below `max_version`) of a
10067    /// community's control plane onto a second relay URL — the withholding-relay
10068    /// simulation: everything it serves is genuinely owner-signed, just stale.
10069    async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
10070        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10071        let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10072        let wraps = relay.fetch(&query, &community.relays).await.unwrap();
10073        for w in &wraps {
10074            if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10075                if ed.version <= max_version {
10076                    relay.inject(w, &[stale_relay.to_string()]);
10077                }
10078            }
10079        }
10080    }
10081
10082    #[tokio::test]
10083    async fn a_withholding_relay_cannot_roll_back_a_rename() {
10084        // W2 persisted floor: after adopting the owner's v2 rename, a relay serving
10085        // only the (owner-signed) v1 genesis must not revert the held name.
10086        let (_tmp, _guard, owner) = init_test_db();
10087        let relay = MemoryRelay::new();
10088        let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
10089        publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
10090
10091        let session = SessionGuard::capture();
10092        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
10093        assert_eq!(updated.name, "Renamed");
10094
10095        // The stale relay holds only the genesis prefix; point the follow at it.
10096        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
10097        let mut stale_view = updated.clone();
10098        stale_view.relays = vec!["wss://stale".into()];
10099        assert!(
10100            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10101            "a stale-only relay must not change the held view"
10102        );
10103        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10104        assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
10105    }
10106
10107    #[tokio::test]
10108    async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
10109        let (_tmp, _guard, owner) = init_test_db();
10110        let relay = MemoryRelay::new();
10111        let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
10112        let extra = ChannelId([0x44; 32]);
10113        let session = SessionGuard::capture();
10114
10115        // A same-content metadata edit: no visible change (None), but the floor must
10116        // still advance to v2 (so the genesis metadata can't re-present below).
10117        publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
10118        assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
10119
10120        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
10121        let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
10122        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
10123        let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
10124        assert!(pruned.channel(&extra).is_none());
10125
10126        // The stale relay serves the add (v1) but withholds the delete (v2).
10127        inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
10128        let mut stale_view = pruned.clone();
10129        stale_view.relays = vec!["wss://stale".into()];
10130        assert!(
10131            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10132            "the withheld delete must not resurrect the channel"
10133        );
10134        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10135        assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
10136    }
10137
10138    #[tokio::test]
10139    async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
10140        // The Armada-convergence carve-out: a Refounding compacts the chain and
10141        // re-wraps a detached head at the NEW epoch's control plane. The old epoch's
10142        // floor must not block it — epoch-filtering makes the entity bootstrap.
10143        let (_tmp, _guard, owner) = init_test_db();
10144        let relay = MemoryRelay::new();
10145        let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
10146        let session = SessionGuard::capture();
10147        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10148        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
10149        assert_eq!(updated.name, "Edited");
10150
10151        // Refounding lands (epoch bump saved by the rekey path); the compacted head
10152        // arrives DETACHED (high version, no prev) on the new epoch's plane.
10153        let mut refounded = updated.clone();
10154        refounded.root_epoch = crate::community::Epoch(1);
10155        crate::db::community::save_community_v2(&refounded).unwrap();
10156        publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
10157
10158        let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
10159        assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
10160        // The persisted floor is stamped with the epoch the FOLD ran under.
10161        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10162        let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
10163        assert!(
10164            heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
10165            "the adopted head carries the fold's epoch + version"
10166        );
10167    }
10168
10169    #[tokio::test]
10170    async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
10171        // Two owner-signed editions at the SAME version (publish retry / two owner
10172        // devices): every client must land on the lower-inner-id winner. A hash-strict
10173        // floor would wedge here forever while Armada converges — the floor must
10174        // CONVERGE instead (the v1 decide() rule).
10175        let (_tmp, _guard, owner) = init_test_db();
10176        let relay = MemoryRelay::new();
10177        let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
10178        let session = SessionGuard::capture();
10179        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10180        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10181
10182        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10183        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10184        assert_eq!(ours.name, "Ours");
10185
10186        // Our committed v2 edition's tiebreak id.
10187        let our_inner = {
10188            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10189            let wraps = relay.fetch(&q, &community.relays).await.unwrap();
10190            wraps
10191                .iter()
10192                .find_map(|w| {
10193                    control::open_control_edition(w, &group)
10194                        .ok()
10195                        .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
10196                        .map(|(ed, _)| ed.inner_id)
10197                })
10198                .unwrap()
10199        };
10200
10201        // Craft the concurrent fork so it WINS the deterministic tiebreak (vary the
10202        // authored timestamp until its inner id is lower).
10203        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10204        let content = serde_json::to_string(&meta).unwrap();
10205        let mut ts = 2_000u64;
10206        let fork_wrap = loop {
10207            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10208            let inner = rumor.id.unwrap().to_bytes();
10209            if inner < our_inner {
10210                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10211            }
10212            ts += 1;
10213        };
10214        relay.publish(&fork_wrap, &community.relays).await.unwrap();
10215
10216        let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
10217        assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
10218        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10219        let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
10220        assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
10221    }
10222
10223    #[tokio::test]
10224    async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
10225        // v2 chains to the floor; v4 arrives but its v3 link is withheld. The
10226        // chain-verified prefix (v2) applies NOW — refuse-downgrade holds for it —
10227        // while the detached v4 waits. When v3 lands, the chain heals to v4.
10228        let (_tmp, _guard, owner) = init_test_db();
10229        let relay = MemoryRelay::new();
10230        let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
10231        let session = SessionGuard::capture();
10232        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10233
10234        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10235        let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10236
10237        // Craft v3 (held back) and v4 (published, chained to the withheld v3).
10238        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10239        let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
10240        let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
10241        let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
10242        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10243        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
10244        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
10245        relay.publish(&w4, &community.relays).await.unwrap();
10246
10247        let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
10248        assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
10249
10250        relay.publish(&w3, &community.relays).await.unwrap();
10251        let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
10252        assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
10253    }
10254
10255    #[tokio::test]
10256    async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
10257        // The held floor is v2; the owner publishes v3, then a flood of foreign junk
10258        // wraps fills the newest window, then v4. Page 1 sees only v4 (detached →
10259        // gapped); paging older must recover v3 (and the floor link) and heal to v4.
10260        let (_tmp, _guard, owner) = init_test_db();
10261        let relay = MemoryRelay::new();
10262        let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
10263        let session = SessionGuard::capture();
10264        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10265
10266        publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10267        let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
10268        publish_community_meta(&relay, &base, &owner, "Three", 3).await; // ts 1_000 (old)
10269        let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10270
10271        // Rogue flood occupying the newest window (sealed to the control plane, but
10272        // non-owner — the authority gate drops them; they only crowd the page).
10273        let rogue = Keys::generate();
10274        for i in 0..(FOLLOW_PAGE as u64 - 1) {
10275            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
10276            let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
10277            relay.publish(&w, &community.relays).await.unwrap();
10278        }
10279        // v4 chained to the real v3 (crafted directly: the flood also blinds the
10280        // helper's own newest-window head lookup), timestamped newest of all.
10281        let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10282        let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
10283        let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
10284        relay.publish(&w4, &community.relays).await.unwrap();
10285
10286        let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
10287        assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
10288    }
10289
10290    #[tokio::test]
10291    async fn a_follow_after_delete_does_not_resurrect_the_community() {
10292        // A leave/delete racing an in-flight follow: the follow must not re-insert
10293        // the community row or floor rows past delete_community's wipe.
10294        let (_tmp, _guard, owner) = init_test_db();
10295        let relay = MemoryRelay::new();
10296        let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
10297        publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10298        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10299        crate::db::community::delete_community(&cid_hex).unwrap();
10300
10301        let session = SessionGuard::capture();
10302        assert!(
10303            follow_control(&relay, &community, &session).await.unwrap().is_none(),
10304            "a follow racing a delete is a no-op"
10305        );
10306        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10307        assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
10308    }
10309
10310    #[tokio::test]
10311    async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
10312        // The rekey sibling of the follow_control guard: an owner rotation adopted
10313        // mid-race must not upsert the community row back after a leave/delete.
10314        let (_tmp, _guard, owner) = init_test_db();
10315        let relay = MemoryRelay::new();
10316        let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
10317        let new_root = [0xB2; 32];
10318        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10319        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10320        crate::db::community::delete_community(&cid_hex).unwrap();
10321
10322        let session = SessionGuard::capture();
10323        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10324        assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
10325        assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10326    }
10327
10328    #[tokio::test]
10329    async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
10330        // {v1, v3} on the relays with v2 lost at publish time (a rate-limiting relay
10331        // that still ACKed): the genesis anchors, so an anchored-prefix-first fold
10332        // would take v1 and SEED the joiner's floor there — pinning them below the
10333        // head Armada shows, forever. A joiner (floor 0) must bootstrap v3.
10334        let (bed, owner, member) = TestBed::new();
10335        bed.swap_to(&owner);
10336        let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
10337        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10338        let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
10339
10340        // v2 is crafted but NEVER published; v3 chains to it and is published.
10341        let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
10342        let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
10343        let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
10344        let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
10345        let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10346        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);
10347        let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
10348        bed.relay.publish(&w3, &community.relays).await.unwrap();
10349
10350        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10351        let bundle_json = serde_json::to_string(&bundle).unwrap();
10352        bed.swap_to(&member);
10353        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10354        assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
10355        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10356        let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
10357        assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
10358    }
10359
10360    #[tokio::test]
10361    async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
10362        // The refusal half of fork convergence: a relay withholding OUR committed
10363        // floor edition while serving only a same-version fork with a HIGHER inner
10364        // id must be treated as withholding — held state and floor unchanged.
10365        let (_tmp, _guard, owner) = init_test_db();
10366        let relay = MemoryRelay::new();
10367        let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
10368        let session = SessionGuard::capture();
10369        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10370        let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10371
10372        publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10373        let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10374        assert_eq!(ours.name, "Ours");
10375        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10376        let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10377        let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
10378
10379        // Grind the fork to LOSE the tiebreak (higher inner id), then serve it —
10380        // with the genesis but WITHOUT our v2 — from a withholding relay.
10381        let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10382        let content = serde_json::to_string(&meta).unwrap();
10383        let mut ts = 5_000u64;
10384        let fork_wrap = loop {
10385            let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10386            if rumor.id.unwrap().to_bytes() > our_inner {
10387                break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10388            }
10389            ts += 1;
10390        };
10391        inject_stale_prefix(&relay, &community, 1, "wss://stale").await; // genesis only
10392        relay.inject(&fork_wrap, &["wss://stale".to_string()]);
10393        let mut stale_view = ours.clone();
10394        stale_view.relays = vec!["wss://stale".into()];
10395
10396        assert!(
10397            follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10398            "a losing fork served without our floor edition changes nothing"
10399        );
10400        let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10401        assert_eq!(held_after, held_before, "the floor row is untouched");
10402        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10403        assert_eq!(held.name, "Ours", "the held state is untouched");
10404    }
10405
10406    #[tokio::test]
10407    async fn follow_control_ignores_a_non_owner_edition() {
10408        // A member holds the community_root, so they CAN seal a control edition —
10409        // but they aren't the owner, so the authority gate drops it (first cut:
10410        // owner-only). The rogue channel must never appear.
10411        let (_tmp, _guard, _owner) = init_test_db();
10412        let relay = MemoryRelay::new();
10413        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10414        let rogue = Keys::generate();
10415        let rogue_id = ChannelId([0x99; 32]);
10416        publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
10417
10418        let session = SessionGuard::capture();
10419        assert!(
10420            follow_control(&relay, &community, &session).await.unwrap().is_none(),
10421            "a non-owner control edition is not folded"
10422        );
10423    }
10424
10425    #[tokio::test]
10426    async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
10427        // A Private channel's key rides the rekey plane, not the control edition —
10428        // control-follow records it KEYLESS (epoch 0, the rekey-scan cursor), and
10429        // every read/send path refuses it until the key lands (never the root plane).
10430        let (_tmp, _guard, owner) = init_test_db();
10431        let relay = MemoryRelay::new();
10432        let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
10433        let priv_id = ChannelId([0x33; 32]);
10434        publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
10435
10436        let session = SessionGuard::capture();
10437        let updated = follow_control(&relay, &community, &session)
10438            .await
10439            .unwrap()
10440            .expect("the keyless record is a change");
10441        let ch = updated.channel(&priv_id).expect("the private channel is recorded");
10442        assert!(ch.private && ch.key.is_none(), "recorded keyless");
10443        assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
10444        assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
10445        assert!(
10446            fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
10447            "a keyless fetch returns empty (and never queries the root plane)"
10448        );
10449        assert!(
10450            send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
10451            "a keyless send refuses"
10452        );
10453        // The keyless record round-trips (the stored placeholder never surfaces
10454        // as a real key).
10455        let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
10456        let rch = reloaded.channel(&priv_id).unwrap();
10457        assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
10458        // And a bundle minted while keyless never carries the placeholder — a
10459        // MEMBER audience, so it's the keyless filter proving it (the link
10460        // filter would drop the channel for the weaker reason).
10461        let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10462        assert!(
10463            !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
10464            "an ungrantable keyless channel stays out of invite bundles"
10465        );
10466    }
10467
10468    #[tokio::test]
10469    async fn a_link_bundle_never_carries_a_private_channel_key() {
10470        // A link's audience holds no Role by construction (CORD-05), so a HELD
10471        // private key must never ride a link bundle — anyone with the URL would
10472        // get the channel. A member bundle carries it; a link bundle only the
10473        // public channels.
10474        let (_tmp, _guard, _owner) = init_test_db();
10475        let relay = MemoryRelay::new();
10476        let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
10477        create_private_channel(&relay, &community, "mods").await.unwrap();
10478        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10479        let priv_hex = held
10480            .channels
10481            .iter()
10482            .find(|c| c.private)
10483            .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
10484            .expect("the private channel is held WITH its key");
10485
10486        let link = bundle_of(&held, BundleAudience::Link, None, None, None);
10487        assert!(
10488            !link.channels.iter().any(|c| c.id == priv_hex),
10489            "a held private key must never ride a link bundle"
10490        );
10491        assert!(
10492            link.channels.iter().any(|c| c.id != priv_hex),
10493            "the public channels still ride it"
10494        );
10495
10496        // A member bundle grants it only to the ENTITLED. An unrelated npub holds
10497        // no scoped role, so it gets nothing; the creator (granted the companion
10498        // access role at create) gets the key.
10499        let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10500        assert!(
10501            !stranger.channels.iter().any(|c| c.id == priv_hex),
10502            "an unentitled member gets no private key"
10503        );
10504        let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
10505        assert!(
10506            mine.channels.iter().any(|c| c.id == priv_hex),
10507            "the creator is entitled via the companion access role"
10508        );
10509    }
10510
10511    #[tokio::test]
10512    async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
10513        // CORD-03/04: the roles scoped to a channel ARE its access list. Proven
10514        // against a NON-owner so the owner-is-always-entitled rule can't carry it.
10515        let (_tmp, _guard, _owner) = init_test_db();
10516        let relay = MemoryRelay::new();
10517        let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
10518        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10519        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10520        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10521
10522        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10523        let access = roster.channel_roles(&chan_hex);
10524        assert_eq!(access.len(), 1, "the channel minted exactly one access role");
10525        assert!(
10526            access[0].permissions == crate::community::roles::Permissions::empty(),
10527            "the access role confers READ access (key possession), never authority"
10528        );
10529        assert_eq!(access[0].name, "mods", "named for its channel");
10530
10531        // A stranger holds no scoped role: unentitled, and no key rides their bundle.
10532        let stranger = Keys::generate().public_key();
10533        let owner_hex = community.owner().unwrap().to_hex();
10534        assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
10535
10536        // Granting the access role entitles them; revoking un-entitles them. Both
10537        // proven through the roster, which is what routes keys.
10538        let role_id = access[0].role_id.clone();
10539        assert!(
10540            roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
10541            "the grant overlay entitles before the fold catches up"
10542        );
10543
10544        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10545        grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10546        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10547        assert!(
10548            after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10549            "the grant landed in the local roster (the fold runs later)"
10550        );
10551        let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
10552        assert!(
10553            vend.channels.iter().any(|c| c.id == chan_hex),
10554            "a now-entitled member's bundle carries the channel key"
10555        );
10556
10557        revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10558        let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10559        assert!(
10560            !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10561            "the revoke dropped the access role"
10562        );
10563        let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10564        assert_eq!(
10565            rotated.channel(&priv_id).unwrap().epoch,
10566            Epoch(2),
10567            "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
10568        );
10569
10570        // The access summary a bot reads back: roles, holders, and key state.
10571        let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
10572        assert_eq!(access["private"], true);
10573        assert_eq!(access["readable"], true, "we minted it, so we hold its key");
10574        assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
10575        let holders = access["members"].as_array().unwrap();
10576        let me_npub = {
10577            use nostr_sdk::prelude::ToBech32;
10578            me_pk().unwrap().to_bech32().unwrap()
10579        };
10580        assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
10581        assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
10582    }
10583
10584    #[tokio::test]
10585    async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
10586        // CORD-06 §1 per channel: a Public-community ban skips the Refounding
10587        // (CORD-05 §5), so without this rotation the banned member keeps each
10588        // held channel key and reads on forever. Only channels they could
10589        // actually reach rotate — the rest keep their epoch.
10590        let (_tmp, _guard, _owner) = init_test_db();
10591        let relay = MemoryRelay::new();
10592        let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
10593        let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
10594        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10595        let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
10596        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10597
10598        let spammer = Keys::generate().public_key();
10599        let bystander = Keys::generate().public_key();
10600        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10601        grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
10602        grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
10603
10604        // The ban composition's capture-then-strip: entitlement must be judged
10605        // from the PRE-strip roles, whether or not the strip has folded.
10606        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10607        let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
10608        assert!(!stripped.is_empty(), "the grant landed before the strip");
10609        grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
10610
10611        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10612        let rotated = sever_banned_private_reads(&relay, &held, &spammer, &stripped).await.unwrap();
10613        assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
10614        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10615        assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
10616        assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
10617
10618        // A member who never had reach rotates nothing.
10619        let stranger = Keys::generate().public_key();
10620        let n = sever_banned_private_reads(&relay, &after, &stranger, &[]).await.unwrap();
10621        assert_eq!(n, 0, "no entitlement, no rotation");
10622    }
10623
10624    #[tokio::test]
10625    async fn ban_severance_refuses_without_manage_channels() {
10626        // Offer-side mirror of `channel_rotator_ok`: readers honor a channel
10627        // rotation only from MANAGE_CHANNELS holders, so a BAN-only moderator
10628        // publishing one would adopt an epoch every reader rejects — a fork.
10629        let (bed, owner, moderator) = TestBed::new();
10630        bed.swap_to(&owner);
10631        let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
10632        create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10633        let rid = "c1".repeat(32);
10634        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10635        publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
10636        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10637        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10638        let bundle_json = serde_json::to_string(&bundle).unwrap();
10639
10640        bed.swap_to(&moderator);
10641        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10642        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10643        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10644        let target = Keys::generate().public_key();
10645        let err = sever_banned_private_reads(&bed.relay, &joined, &target, &[]).await.unwrap_err();
10646        assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
10647    }
10648
10649    #[tokio::test]
10650    async fn set_banlist_refuses_an_author_the_fold_would_reject() {
10651        // The reader gates the banlist head on BAN and each added entry on strict
10652        // outrank. Publishing anyway would be silently void everywhere while the
10653        // author's own echo caches the phantom — fail-closed at the offer instead.
10654        let (bed, owner, member) = TestBed::new();
10655        bed.swap_to(&owner);
10656        let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
10657        let rid = "c2".repeat(32);
10658        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10659        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10660        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10661        let bundle_json = serde_json::to_string(&bundle).unwrap();
10662
10663        // A roleless member holds no BAN: refused before any publish.
10664        bed.swap_to(&member);
10665        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10666        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10667        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10668        let target = Keys::generate().public_key().to_hex();
10669        let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
10670        assert!(err.contains("BAN"), "refused for the missing bit: {err}");
10671        assert!(
10672            crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
10673            "no phantom echo cached"
10674        );
10675
10676        // Grant them BAN: adding a peer they don't outrank still refuses (the
10677        // fold drops per-target on outrank), while an unranked target passes.
10678        bed.swap_to(&owner);
10679        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
10680        let peer = Keys::generate().public_key();
10681        publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
10682        bed.swap_to(&member);
10683        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10684        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10685        let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
10686        assert!(err.contains("outrank"), "an equal is not actionable: {err}");
10687        set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
10688    }
10689
10690    #[tokio::test]
10691    async fn metadata_and_link_minting_refuse_unauthorized_authors() {
10692        // Both are reader-gated (MANAGE_METADATA; CREATE_INVITE on the registry) —
10693        // the offer must mirror or the SDK reports success on a void publish.
10694        let (bed, owner, member) = TestBed::new();
10695        bed.swap_to(&owner);
10696        let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
10697        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10698        let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10699        let bundle_json = serde_json::to_string(&bundle).unwrap();
10700
10701        bed.swap_to(&member);
10702        let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10703        let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10704        let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10705        let meta = control::CommunityMetadata {
10706            name: "Hijacked".into(),
10707            description: None,
10708            relays: vec![],
10709            icon: None,
10710            banner: None,
10711            custom: None,
10712            extra: Default::default(),
10713        };
10714        assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
10715        assert!(
10716            mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
10717            "link minting refused"
10718        );
10719    }
10720
10721    #[tokio::test]
10722    async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
10723        // JSKitty's race: the vend can land BEFORE the control fold that proves
10724        // the grant. It must park quietly (a lagging fold is not an anomaly) and
10725        // be adopted on the re-judge once the roster catches up.
10726        let (bed, owner, member) = TestBed::new();
10727        bed.swap_to(&owner);
10728        let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
10729        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10730        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10731        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10732        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10733        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10734        let owner_hex = community.owner().unwrap().to_hex();
10735
10736        // Judge as the MEMBER — the owner is always entitled, so only a non-owner
10737        // can exercise the grant rule at all.
10738        bed.swap_to(&member);
10739        let me = member.keys.public_key().to_hex();
10740        // Their fold has the channel (control-follow records it keyless) but not
10741        // yet the grant that entitles them.
10742        let mut member_view = held.clone();
10743        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10744            c.key = None;
10745            c.epoch = Epoch(0);
10746        }
10747
10748        // Ungranted → PARK, never refuse: this is exactly the "not synced enough
10749        // to judge" case, and it must stay quiet and retryable.
10750        let empty = crate::community::roles::CommunityRoles::default();
10751        assert!(matches!(
10752            judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
10753            VendVerdict::Park(_)
10754        ));
10755
10756        // A channel our fold says is PUBLIC never heals — that's a spoof shape.
10757        let mut public_view = member_view.clone();
10758        if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10759            c.private = false;
10760        }
10761        assert!(matches!(
10762            judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
10763            VendVerdict::Refuse(_)
10764        ));
10765
10766        // An unknown channel parks (our fold may simply be behind), never refuses.
10767        assert!(matches!(
10768            judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
10769            VendVerdict::Park(_)
10770        ));
10771
10772        // Park the vend, then re-judge with a roster that still lacks our grant:
10773        // it must SURVIVE, not be discarded.
10774        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10775        crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
10776        crate::db::community::save_community_v2(&member_view).unwrap();
10777        let session = SessionGuard::capture();
10778        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10779        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
10780        assert_eq!(
10781            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10782            1,
10783            "and stays parked for the next fold"
10784        );
10785
10786        // The fold catches up: our grant lands, so the same vend now adopts.
10787        let access = crate::community::roles::Role {
10788            role_id: "44".repeat(32),
10789            name: "mods".into(),
10790            position: u32::MAX - 1,
10791            permissions: crate::community::roles::Permissions::empty(),
10792            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10793            color: 0,
10794        };
10795        let folded = crate::community::roles::CommunityRoles {
10796            grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
10797            roles: vec![access],
10798        };
10799        crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
10800        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10801        let adopted = absorb_parked_channel_keys(&reloaded, &session);
10802        assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
10803
10804        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10805        let ch = after.channel(&priv_id).unwrap();
10806        assert_eq!(ch.key, Some(real_key), "adopted the vended key");
10807        assert_eq!(ch.epoch, Epoch(1));
10808        assert!(
10809            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10810            "and the park is discharged"
10811        );
10812    }
10813
10814    #[tokio::test]
10815    async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
10816        // Live cross-client finding: a peer that mints born-private channels at
10817        // epoch 0 vends epoch 0, which collides with our keyless cursor (also 0).
10818        // The monotonic guard (`new > current`) would refuse the only key we are
10819        // ever offered, and refuse it SILENTLY. First delivery is not a rotation.
10820        let (bed, owner, member) = TestBed::new();
10821        bed.swap_to(&owner);
10822        let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
10823        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10824        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10825        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10826        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10827        let vended = [0x5a; 32];
10828        let owner_hex = community.owner().unwrap().to_hex();
10829
10830        bed.swap_to(&member);
10831        // The member's view: channel known, keyless, parked at the epoch-0 cursor.
10832        let mut member_view = held.clone();
10833        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10834            c.key = None;
10835            c.epoch = Epoch(0);
10836        }
10837        crate::db::community::save_community_v2(&member_view).unwrap();
10838
10839        // Entitle them, then park a vend AT EPOCH 0 (what the peer actually sends).
10840        let access = crate::community::roles::Role {
10841            role_id: "77".repeat(32),
10842            name: "mods".into(),
10843            position: u32::MAX - 1,
10844            permissions: crate::community::roles::Permissions::empty(),
10845            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10846            color: 0,
10847        };
10848        let roster = crate::community::roles::CommunityRoles {
10849            grants: vec![crate::community::roles::MemberGrant {
10850                member: member.keys.public_key().to_hex(),
10851                role_ids: vec![access.role_id.clone()],
10852            }],
10853            roles: vec![access],
10854        };
10855        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10856        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
10857
10858        let session = SessionGuard::capture();
10859        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10860        let adopted = absorb_parked_channel_keys(&reloaded, &session);
10861        assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
10862
10863        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10864        let ch = after.channel(&priv_id).unwrap();
10865        assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
10866        assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
10867        assert!(
10868            !after.channel_read_coords(ch).is_empty(),
10869            "and the channel is readable — the whole point"
10870        );
10871        assert!(
10872            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10873            "the park is discharged"
10874        );
10875    }
10876
10877    #[tokio::test]
10878    async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
10879        // The channel head is MONOTONIC, so over-advancing it can never be walked
10880        // back: every genuine rotation afterwards lands at head+1, reads as stale,
10881        // and the channel dies for us with no heal path at all. An entitled
10882        // insider vending a garbage key costs isolation (accepted); one vending a
10883        // garbage EPOCH would cost the channel permanently, which is not.
10884        let (bed, owner, member) = TestBed::new();
10885        bed.swap_to(&owner);
10886        let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
10887        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10888        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10889        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10890        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10891        let owner_hex = community.owner().unwrap().to_hex();
10892
10893        bed.swap_to(&member);
10894        let mut member_view = held.clone();
10895        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10896            c.key = None;
10897            c.epoch = Epoch(0);
10898        }
10899        crate::db::community::save_community_v2(&member_view).unwrap();
10900        let access = crate::community::roles::Role {
10901            role_id: "99".repeat(32),
10902            name: "mods".into(),
10903            position: u32::MAX - 1,
10904            permissions: crate::community::roles::Permissions::empty(),
10905            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10906            color: 0,
10907        };
10908        let roster = crate::community::roles::CommunityRoles {
10909            grants: vec![crate::community::roles::MemberGrant {
10910                member: member.keys.public_key().to_hex(),
10911                role_ids: vec![access.role_id.clone()],
10912            }],
10913            roles: vec![access],
10914        };
10915        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10916        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10917
10918        // Everything else about this vend is valid — only the epoch is absurd.
10919        assert!(matches!(
10920            judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
10921            VendVerdict::Refuse(_)
10922        ));
10923        // REFUSED, not parked: a row nothing can ever discharge is its own leak.
10924        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
10925        let session = SessionGuard::capture();
10926        assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
10927        assert!(
10928            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10929            "and the row is discharged rather than parked forever"
10930        );
10931        // The head is untouched, so the genuine vend still lands afterwards.
10932        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10933        assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
10934        assert!(matches!(
10935            judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
10936            VendVerdict::Accept
10937        ));
10938    }
10939
10940    #[tokio::test]
10941    async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
10942        // The fold is the authority but runs later, so publishing alone leaves the
10943        // edit reading back stale — it looks like the rename silently failed.
10944        let (_tmp, _guard, _owner) = init_test_db();
10945        let relay = MemoryRelay::new();
10946        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10947        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10948        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10949        let key_before = held.channel(&priv_id).unwrap().key;
10950
10951        let mut meta = held.channel(&priv_id).unwrap().metadata();
10952        meta.name = "staff".into();
10953        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10954
10955        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10956        let ch = after.channel(&priv_id).unwrap();
10957        assert_eq!(ch.name, "staff", "the rename is visible immediately");
10958        assert!(ch.private, "and privacy survives the edit");
10959        assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10960    }
10961
10962    #[tokio::test]
10963    async fn a_channel_rename_carries_its_companion_access_role() {
10964        let (_tmp, _guard, _owner) = init_test_db();
10965        let relay = MemoryRelay::new();
10966        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10967        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10968        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10969        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10970
10971        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10972        let before = roster.channel_roles(&chan_hex);
10973        assert_eq!(before.len(), 1, "one companion role, minted at create");
10974        assert_eq!(before[0].name, "mods", "named after the channel it gates");
10975        let role_id = before[0].role_id.clone();
10976
10977        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10978        let mut meta = held.channel(&priv_id).unwrap().metadata();
10979        meta.name = "staff".into();
10980        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10981
10982        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10983        let after = roster.channel_roles(&chan_hex);
10984        assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10985        assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10986        assert_eq!(after[0].name, "staff", "the access role followed the channel");
10987        assert_eq!(
10988            after[0].permissions,
10989            crate::community::roles::Permissions::empty(),
10990            "and still confers read access, never authority"
10991        );
10992    }
10993
10994    #[tokio::test]
10995    async fn a_customised_access_role_name_survives_a_channel_rename() {
10996        // The label is cosmetic — entitlement rides the scope. Overwriting a name
10997        // someone chose deliberately is the surprising half of "keep them in step".
10998        let (_tmp, _guard, _owner) = init_test_db();
10999        let relay = MemoryRelay::new();
11000        let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
11001        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
11002        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11003        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
11004
11005        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11006        let mut role = roster.channel_roles(&chan_hex)[0].clone();
11007        role.name = "Lab Insiders".into();
11008        set_role(&relay, &community, &role).await.unwrap();
11009        merge_local_roster(&cid_hex, Some(&role), None);
11010
11011        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11012        let mut meta = held.channel(&priv_id).unwrap().metadata();
11013        meta.name = "staff".into();
11014        edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
11015
11016        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11017        assert_eq!(
11018            roster.channel_roles(&chan_hex)[0].name,
11019            "Lab Insiders",
11020            "a deliberate name is left alone"
11021        );
11022    }
11023
11024    #[tokio::test]
11025    async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
11026        // Parking is reachable by ANY npub that can gift-wrap us — the bundle
11027        // self-certifies and its inputs are public for a public community. With a
11028        // single slot per channel, a stranger could pre-park and the admin's real
11029        // vend would be a silent no-op, leaving the member keyless with no retry.
11030        // Candidates + judge-them-all is what closes that.
11031        let (bed, owner, member) = TestBed::new();
11032        bed.swap_to(&owner);
11033        let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
11034        let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11035        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11036        let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
11037        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11038        let real_key = held.channel(&priv_id).unwrap().key.unwrap();
11039        let owner_hex = community.owner().unwrap().to_hex();
11040
11041        bed.swap_to(&member);
11042        let mut member_view = held.clone();
11043        if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
11044            c.key = None;
11045            c.epoch = Epoch(0);
11046        }
11047        crate::db::community::save_community_v2(&member_view).unwrap();
11048        let access = crate::community::roles::Role {
11049            role_id: "aa".repeat(32),
11050            name: "mods".into(),
11051            position: u32::MAX - 1,
11052            permissions: crate::community::roles::Permissions::empty(),
11053            scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
11054            color: 0,
11055        };
11056        let roster = crate::community::roles::CommunityRoles {
11057            grants: vec![crate::community::roles::MemberGrant {
11058                member: member.keys.public_key().to_hex(),
11059                role_ids: vec![access.role_id.clone()],
11060            }],
11061            roles: vec![access],
11062        };
11063        crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
11064
11065        // A stranger squats FIRST, at a higher epoch than the genuine vend.
11066        let stranger = Keys::generate().public_key().to_hex();
11067        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
11068        // The admin's real vend arrives after, at the true epoch.
11069        crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
11070        assert_eq!(
11071            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
11072            2,
11073            "the squatter never displaces the genuine vend — both are candidates"
11074        );
11075
11076        let session = SessionGuard::capture();
11077        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11078        let adopted = absorb_parked_channel_keys(&reloaded, &session);
11079        assert_eq!(adopted.len(), 1, "exactly one adoption");
11080
11081        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11082        let ch = after.channel(&priv_id).unwrap();
11083        assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
11084        assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
11085        assert!(
11086            crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
11087            "and every candidate for the channel is discharged"
11088        );
11089    }
11090
11091    #[tokio::test]
11092    async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
11093        // With no access role folded, the retained-set filter matches NOBODY, so
11094        // the rotation would cut off every legitimately entitled member while the
11095        // Grant it published revoked nothing. Reachable with no attacker: the
11096        // channel was made on another admin's client and its role hasn't folded.
11097        let (_tmp, _guard, _owner) = init_test_db();
11098        let relay = MemoryRelay::new();
11099        let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
11100        let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
11101        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11102        let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11103        let before = held.channel(&priv_id).unwrap().epoch;
11104
11105        // Neither the cache nor the plane serves the access role — a withholding
11106        // relay, or a channel minted on another admin's client. (Wiping only the
11107        // cache is no longer enough: the revoke re-fetches authority first.)
11108        crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
11109        let mut blind = held.clone();
11110        blind.relays = vec!["wss://empty".into()];
11111        let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
11112            .await
11113            .unwrap_err();
11114        assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
11115
11116        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11117        assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
11118    }
11119
11120    // ── Live rekey-follow ────────────────────────────────────────────────────
11121
11122    /// Publish an owner-grammar base rotation (Refounding) delivering `new_root`
11123    /// to each recipient. `rotator` is the seal signer (owner for a legit rotation,
11124    /// a stranger for the authority test); `prev_key` is the root it claims to
11125    /// extend (mismatch → a fork).
11126    async fn publish_base_rotation(
11127        relay: &MemoryRelay,
11128        community: &CommunityV2,
11129        rotator: &Keys,
11130        recipients: &[PublicKey],
11131        new_root: &[u8; 32],
11132        prev_key: &[u8; 32],
11133    ) {
11134        let new_epoch = Epoch(community.root_epoch.0 + 1);
11135        let prev_epoch = community.root_epoch;
11136        let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
11137        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11138        let blobs: Vec<_> = recipients
11139            .iter()
11140            .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
11141            .collect();
11142        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();
11143        for e in &events {
11144            relay.publish(e, &community.relays).await.unwrap();
11145        }
11146    }
11147
11148    /// Attach a Private channel (key + epoch) to a held community and persist it.
11149    fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
11150        community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
11151        crate::db::community::save_community_v2(community).unwrap();
11152    }
11153
11154    #[tokio::test]
11155    async fn follow_rekeys_is_a_noop_without_rotations() {
11156        let (_tmp, _guard, _owner) = init_test_db();
11157        let relay = MemoryRelay::new();
11158        let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
11159        let session = SessionGuard::capture();
11160        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11161        assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
11162    }
11163
11164    #[tokio::test]
11165    async fn follow_rekeys_adopts_an_owner_base_rotation() {
11166        let (_tmp, _guard, owner) = init_test_db();
11167        let relay = MemoryRelay::new();
11168        let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
11169        let new_root = [0xB1; 32];
11170        // Owner rotates the base to epoch 1, delivering the new root to me.
11171        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
11172
11173        let session = SessionGuard::capture();
11174        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
11175        assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
11176        assert_eq!(updated.community_root, new_root, "adopted the fresh root");
11177        // The public channel now reads under the NEW root/epoch (its address moved).
11178        let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
11179        let general = updated.channels[0].id;
11180        let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
11181        assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
11182    }
11183
11184    #[tokio::test]
11185    async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
11186        let (_tmp, _guard, owner) = init_test_db();
11187        let relay = MemoryRelay::new();
11188        let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
11189        let priv_id = ChannelId([0x33; 32]);
11190        add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
11191
11192        // Owner rotates the private channel to epoch 1 with a fresh key, delivered to me.
11193        let new_key = [0x55; 32];
11194        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
11195        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
11196        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();
11197        let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
11198        for e in &events {
11199            relay.publish(e, &community.relays).await.unwrap();
11200        }
11201
11202        let session = SessionGuard::capture();
11203        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
11204        let ch = updated.channel(&priv_id).unwrap();
11205        assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
11206        assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
11207        assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
11208    }
11209
11210    #[tokio::test]
11211    async fn follow_rekeys_ignores_a_non_owner_rotation() {
11212        // A member holds the community_root, so they can derive the rekey group key
11213        // and mint a rotation — but they aren't the owner, so it's not adopted.
11214        let (_tmp, _guard, _owner) = init_test_db();
11215        let relay = MemoryRelay::new();
11216        let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
11217        let rogue = Keys::generate();
11218        publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
11219
11220        let session = SessionGuard::capture();
11221        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11222        assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
11223    }
11224
11225    #[tokio::test]
11226    async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
11227        // A rotation whose prevcommit doesn't match the key I hold is a fork, not an
11228        // extension — never adopted (would splice me onto an unrelated chain).
11229        let (_tmp, _guard, owner) = init_test_db();
11230        let relay = MemoryRelay::new();
11231        let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
11232        // prev_key ≠ the real community_root → the continuity check reads Fork.
11233        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
11234
11235        let session = SessionGuard::capture();
11236        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11237        assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
11238    }
11239
11240    #[tokio::test]
11241    async fn follow_rekeys_holds_on_an_incomplete_rotation() {
11242        // A 2-chunk rotation with only chunk 1 present can never conclude — not an
11243        // adoption, and crucially NOT a removal (a missing chunk might carry my blob).
11244        let (_tmp, _guard, owner) = init_test_db();
11245        let relay = MemoryRelay::new();
11246        let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
11247        let new_epoch = Epoch(1);
11248        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11249        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11250        // Chunk 1 of a declared 2, carrying someone else's blob (not mine).
11251        let other = Keys::generate();
11252        let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
11253        let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
11254        let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
11255        relay.publish(&wrap, &community.relays).await.unwrap();
11256
11257        let session = SessionGuard::capture();
11258        let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11259        assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
11260    }
11261
11262    #[tokio::test]
11263    async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
11264        // Realistic two-actor removal: the owner Refounds the base and delivers the
11265        // new root to a THIRD party, not the member — a complete rotation with no
11266        // blob for the member is a removal.
11267        let (bed, owner, member) = TestBed::new();
11268        bed.swap_to(&owner);
11269        let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
11270        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11271
11272        bed.swap_to(&member);
11273        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11274        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11275
11276        // Owner rotates, delivering only to a stranger (the member is dropped).
11277        bed.swap_to(&owner);
11278        let stranger = Keys::generate();
11279        publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
11280
11281        // The member's follow concludes removal (a complete rotation without their blob).
11282        bed.swap_to(&member);
11283        let session = SessionGuard::capture();
11284        let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11285        assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
11286        assert!(follow.updated.is_none(), "a removed member adopts nothing");
11287    }
11288
11289    #[tokio::test]
11290    async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
11291        // PROTO-B2 regression: a Refounding's channel rekeys ride the PRIOR root
11292        // (CORD-06 §3). A follower who adopted the BASE first (the live window:
11293        // the base crate landed and was walked before the channel crates) must
11294        // still find them — the lookup fans across the archived roots, not just
11295        // the current one.
11296        let (_tmp, _guard, owner) = init_test_db();
11297        let relay = MemoryRelay::new();
11298        let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
11299        let root0 = community.community_root;
11300        let priv_id = ChannelId([0x33; 32]);
11301        let key1 = [0x44; 32];
11302        add_private_channel(&mut community, priv_id, key1, Epoch(1));
11303
11304        // The refounder's channel rekey (1 → 2), sealed + addressed under the PRIOR
11305        // root (root0), delivering the fresh key to me.
11306        let key2 = [0x55; 32];
11307        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11308        let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
11309        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();
11310        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() {
11311            relay.publish(&e, &community.relays).await.unwrap();
11312        }
11313
11314        // Simulate the base having ALREADY advanced (the stranding order): the head
11315        // moved to a fresh root while root0 sits in the epoch-key archive (where
11316        // genesis put it).
11317        community.community_root = [0xB7; 32];
11318        community.root_epoch = Epoch(1);
11319        crate::db::community::save_community_v2(&community).unwrap();
11320
11321        let session = SessionGuard::capture();
11322        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
11323        let ch = updated.channel(&priv_id).unwrap();
11324        assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
11325        assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
11326    }
11327
11328    #[tokio::test]
11329    async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
11330        // A keyless private channel (announced by vsk-2, key not yet held) has no
11331        // chain, so its epoch is a scan cursor: a complete rotation that excludes
11332        // us advances the cursor (never a removal — we were never in); a later
11333        // rotation that includes us is the entry point.
11334        let (_tmp, _guard, owner) = init_test_db();
11335        let relay = MemoryRelay::new();
11336        let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
11337        let priv_id = ChannelId([0x66; 32]);
11338        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() });
11339        crate::db::community::save_community_v2(&community).unwrap();
11340        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11341        assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
11342
11343        // Epoch 1: the creation delivery went to a stranger only (pre-dates us).
11344        let stranger = Keys::generate();
11345        let key1 = [0x71; 32];
11346        let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11347        let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
11348        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();
11349        for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
11350            relay.publish(&e, &community.relays).await.unwrap();
11351        }
11352        // Epoch 2: a later rotation includes ME (e.g. a removal-forced re-mint whose
11353        // recipient set is the CURRENT members).
11354        let key2 = [0x72; 32];
11355        let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11356        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11357        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();
11358        for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
11359            relay.publish(&e, &community.relays).await.unwrap();
11360        }
11361
11362        // ONE follow: the cursor walks 0→1 (excluded, still keyless) and 1→2 (my
11363        // blob — adopt), because each real step re-loops.
11364        let session = SessionGuard::capture();
11365        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
11366        let ch = updated.channel(&priv_id).unwrap();
11367        assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
11368        assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
11369    }
11370
11371    #[tokio::test]
11372    async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
11373        // CORD-06 §Authority: a CHANNEL rekey is honored from the owner or a
11374        // MANAGE_CHANNELS holder under the persisted roster — so an admin-run
11375        // rotation keys members up; a mere keyholder's forgery never does.
11376        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11377        let (_tmp, _guard, _owner) = init_test_db();
11378        let relay = MemoryRelay::new();
11379        let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
11380        let priv_id = ChannelId([0x88; 32]);
11381        let key1 = [0x91; 32];
11382        add_private_channel(&mut community, priv_id, key1, Epoch(1));
11383
11384        // Persist a roster granting `admin` the Admin role (MANAGE_CHANNELS ⊂ ADMIN_ALL).
11385        let admin = Keys::generate();
11386        let role = Role::admin("aa".repeat(32));
11387        let roster = CommunityRoles {
11388            roles: vec![role.clone()],
11389            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11390        };
11391        seed_roster_with_heads(&community, &roster, 1_000);
11392
11393        // The ADMIN rotates the channel 1 → 2, delivering to me: adopted.
11394        let key2 = [0x92; 32];
11395        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11396        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11397        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11398        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11399        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() {
11400            relay.publish(&e, &community.relays).await.unwrap();
11401        }
11402        let session = SessionGuard::capture();
11403        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
11404        assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
11405
11406        // A STRANGER (keyholder, no roster standing) rotates 2 → 3: refused.
11407        let rogue = Keys::generate();
11408        let key3 = [0x93; 32];
11409        let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
11410        let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
11411        let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
11412        for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
11413            relay.publish(&e, &updated.relays).await.unwrap();
11414        }
11415        let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
11416        assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
11417    }
11418
11419    #[tokio::test]
11420    async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
11421        // CORD-06 §Authority: the Rotator must strictly OUTRANK every removed
11422        // target. An equal-rank bit-holder's complete rotation that skips my blob
11423        // must read Stay (my record survives); the OWNER's reads Removed. Needs a
11424        // two-account bed: the follower must be a NON-owner admin.
11425        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11426        let (bed, owner, member) = TestBed::new();
11427        bed.swap_to(&owner);
11428        let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
11429
11430        // The MEMBER's device: holds the community + the private channel, with a
11431        // persisted roster granting the member AND a peer the same Admin role.
11432        bed.swap_to(&member);
11433        let mut held = community.clone();
11434        let priv_id = ChannelId([0xAB; 32]);
11435        let key1 = [0xA1; 32];
11436        add_private_channel(&mut held, priv_id, key1, Epoch(1));
11437        let peer = Keys::generate();
11438        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11439        let role = Role::admin("bb".repeat(32));
11440        let roster = CommunityRoles {
11441            roles: vec![role.clone()],
11442            grants: vec![
11443                MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11444                MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11445            ],
11446        };
11447        crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
11448
11449        // The equal-rank PEER rotates 1 → 2 delivering only to themselves.
11450        let key2 = [0xA2; 32];
11451        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11452        let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
11453        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();
11454        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() {
11455            bed.relay.publish(&e, &held.relays).await.unwrap();
11456        }
11457        let session = SessionGuard::capture();
11458        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11459        assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
11460        let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
11461        assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
11462
11463        // The OWNER's rotation excluding me IS a removal (owner outranks everyone).
11464        let key3 = [0xA3; 32];
11465        let stranger = Keys::generate();
11466        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();
11467        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() {
11468            bed.relay.publish(&e, &held.relays).await.unwrap();
11469        }
11470        let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11471        let updated = follow.updated.expect("the owner's removal folds");
11472        assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
11473    }
11474
11475    #[tokio::test]
11476    async fn converting_a_public_channel_to_private_is_refused() {
11477        // The conversion (CORD-03 §2) is a key rotation this build doesn't mint yet:
11478        // the producer refuses the flag flip, so no reader is left unkeyable.
11479        let (_tmp, _guard, _owner) = init_test_db();
11480        let relay = MemoryRelay::new();
11481        let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
11482        let general = community.channels[0].id;
11483        let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
11484        let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
11485        assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
11486        // A rename of the same public channel still works.
11487        let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
11488        edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
11489    }
11490
11491    /// Publish a 13302 (signed by `me`) carrying a leave tombstone for `cid_hex` at
11492    /// `removed_at` — simulating a sibling device having left that community.
11493    async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
11494        let doc = super::super::list::CommunityList {
11495            entries: vec![],
11496            tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
11497            extra: Default::default(),
11498        };
11499        let event = super::super::list::build_list_event(me, &doc).unwrap();
11500        relay.publish(&event, relays).await.unwrap();
11501    }
11502
11503    #[tokio::test]
11504    async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
11505        // W1 (send side): a sibling device left X (a remote tombstone). Joining a
11506        // DIFFERENT community must not re-add X to the 13302 with added_at=now,
11507        // which would silently undo the leave everywhere.
11508        let (_tmp, _guard, me) = init_test_db();
11509        let relay = MemoryRelay::new();
11510        let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
11511        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11512
11513        // A sibling leaves X: a remote tombstone strictly newer than X's add.
11514        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11515
11516        // Now join a different community Y → republish(just_joined = Y).
11517        let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
11518        republish_community_list(&relay, Some(y.id())).await.unwrap();
11519
11520        // X must still read as LEFT in the published list; Y must be live.
11521        let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
11522        assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
11523        assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
11524    }
11525
11526    #[tokio::test]
11527    async fn sync_tears_down_a_community_a_sibling_left() {
11528        // W1 (receive side): a community still held locally that the synced 13302
11529        // shows tombstoned-and-not-live is torn down, so a leave propagates.
11530        let (_tmp, _guard, me) = init_test_db();
11531        let relay = MemoryRelay::new();
11532        let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
11533        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11534        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
11535
11536        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11537        sync_community_list(&relay, &x.relays).await.unwrap();
11538        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
11539    }
11540
11541    #[tokio::test]
11542    async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
11543        // The re-join case must NOT be torn down: a fresh join re-adds live (beating
11544        // the tombstone), so a later sync keeps it.
11545        let (_tmp, _guard, me) = init_test_db();
11546        let relay = MemoryRelay::new();
11547        let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
11548        let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11549        // A stale tombstone from a prior leave (OLDER than the current hold's re-add).
11550        publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
11551        // Re-record the membership (a re-join) → live entry at now >> 1.
11552        republish_community_list(&relay, Some(x.id())).await.unwrap();
11553        sync_community_list(&relay, &x.relays).await.unwrap();
11554        assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
11555    }
11556
11557    #[tokio::test]
11558    async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
11559        // W2: a transient fetch failure during republish must not drive the
11560        // replaceable-event write (which would drop other entries / regress seeds).
11561        let (_tmp, _guard, _me) = init_test_db();
11562        let good = MemoryRelay::new();
11563        let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
11564        assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
11565
11566        // A transport whose fetch always errors: republish must bail, publishing nothing.
11567        struct FetchErrors;
11568        #[async_trait::async_trait]
11569        impl Transport for FetchErrors {
11570            async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11571            async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11572                panic!("republish must NOT publish when the remote fetch failed");
11573            }
11574            async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11575                Ok(())
11576            }
11577            async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11578                Err("relay unreachable".to_string())
11579            }
11580        }
11581        // Returns Ok (best-effort) but must not have published (the panic guards it).
11582        republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
11583    }
11584
11585    #[tokio::test]
11586    async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
11587        // B1 regression: refound_community's recipient set = memberlist. A member
11588        // the owner GRANTED a role to but who never left a (surviving) Guestbook
11589        // Join — a lurking admin, or one whose Join aged out of the window — must
11590        // still be a rekey recipient, or the Refounding SEVERS them. The folded
11591        // roster's granted members are the consensus-complete backstop.
11592        let (_tmp, _guard, owner) = init_test_db();
11593        let relay = MemoryRelay::new();
11594        let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
11595
11596        // A lurker gets an admin grant but publishes NO Guestbook Join and no chat.
11597        let lurker = Keys::generate();
11598        let rid = "b1".repeat(32);
11599        publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11600        publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
11601
11602        // memberlist includes the lurker purely via the roster backstop.
11603        let members = memberlist(&relay, &community).await.unwrap();
11604        assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
11605
11606        // A banned grantee whose grant wasn't stripped is NOT re-admitted.
11607        let banned_grantee = Keys::generate();
11608        publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
11609        set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
11610        let members = memberlist(&relay, &community).await.unwrap();
11611        assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
11612        assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
11613
11614        // And the Refounding actually delivers the new root to the lurker.
11615        let refounded = refound_community(&relay, &community, &[]).await.unwrap();
11616        assert_eq!(refounded.root_epoch, Epoch(1));
11617        let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11618        let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
11619        let rotations = rekey::collect_rotations(&chunks);
11620        let lurker_x = lurker.public_key().to_bytes();
11621        let delivered = rotations.iter().any(|r| {
11622            rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
11623        });
11624        assert!(delivered, "the Refounding delivered the new root to the granted lurker");
11625    }
11626
11627    #[tokio::test]
11628    async fn the_memberlist_pages_past_a_guestbook_flood() {
11629        // The roleless-member half of B1: >500 Guestbook events must not evict an
11630        // honest member's Join from the counted set (an insider can flood throwaway
11631        // Joins to force exactly this). The pager sees them all.
11632        let (_tmp, _guard, _owner) = init_test_db();
11633        let relay = MemoryRelay::new();
11634        let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
11635        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11636
11637        // An honest member's Join (oldest), then 600 throwaway Joins on top.
11638        let honest = Keys::generate();
11639        let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
11640        let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
11641        relay.publish(&w, &community.relays).await.unwrap();
11642        for i in 0..600u64 {
11643            let throwaway = Keys::generate();
11644            let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
11645            let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
11646            relay.publish(&w, &community.relays).await.unwrap();
11647        }
11648
11649        let members = memberlist(&relay, &community).await.unwrap();
11650        assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
11651    }
11652
11653    #[tokio::test]
11654    async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
11655        // An insider floods the next-epoch rekey address (community_root-derived,
11656        // so any member can seal there) with >200 junk 3303s to push the owner's
11657        // genuine rotation out of a single fetch window. The paginated fetch must
11658        // still recover it and adopt.
11659        let (_tmp, _guard, owner) = init_test_db();
11660        let relay = MemoryRelay::new();
11661        let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
11662        let new_root = [0xD9; 32];
11663        let new_epoch = Epoch(1);
11664        let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11665
11666        // The GENUINE owner rotation lands first (oldest).
11667        publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
11668
11669        // Then a member floods 260 well-formed-but-unauthorized junk chunks ON TOP
11670        // (newer), burying the genuine one past the 200 newest.
11671        let rogue = Keys::generate();
11672        let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11673        for i in 0..260u64 {
11674            let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
11675            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();
11676            let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
11677            relay.publish(&wrap, &community.relays).await.unwrap();
11678        }
11679
11680        let session = SessionGuard::capture();
11681        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
11682        assert_eq!(updated.root_epoch, Epoch(1));
11683        assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
11684    }
11685
11686    #[tokio::test]
11687    async fn a_swap_during_create_private_channel_aborts_without_a_write() {
11688        // create_private_channel publishes the key crate, then the channel
11689        // edition, then whole-row-saves. A swap anywhere in that window must
11690        // abort — never mint a channel into the swapped-in account, and never
11691        // leave a half-published key crate adopted locally.
11692        let (bed, owner, _member) = TestBed::new();
11693        bed.swap_to(&owner);
11694        let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
11695        let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
11696
11697        // The key-crate publish inside create bumps the generation mid-flight.
11698        let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
11699        let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
11700        assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
11701        let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11702        assert_eq!(after.channels.len(), before, "no channel row was written");
11703        assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
11704    }
11705
11706    #[tokio::test]
11707    async fn an_uncited_admin_rotation_is_not_adopted() {
11708        // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
11709        // authority action, so a just-demoted admin's rotation is never honored by
11710        // a lagging client." An uncited rotation is skipped entirely — neither
11711        // adopted nor allowed to conclude a removal.
11712        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11713        let (_tmp, _guard, _owner) = init_test_db();
11714        let relay = MemoryRelay::new();
11715        let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
11716        let priv_id = ChannelId([0x8A; 32]);
11717        let key1 = [0x93; 32];
11718        add_private_channel(&mut community, priv_id, key1, Epoch(1));
11719
11720        let admin = Keys::generate();
11721        let role = Role::admin("cf".repeat(32));
11722        let roster = CommunityRoles {
11723            roles: vec![role.clone()],
11724            grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11725        };
11726        seed_roster_with_heads(&community, &roster, 1_000);
11727
11728        let key2 = [0x94; 32];
11729        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11730        let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11731        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11732        let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11733        // Authorized admin, correct continuity, my blob present — but NO citation.
11734        for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
11735            relay.publish(&e, &community.relays).await.unwrap();
11736        }
11737
11738        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11739        assert!(out.updated.is_none(), "an uncited rotation is not adopted");
11740
11741        // The SAME rotation, cited, is adopted — proving the refusal was the
11742        // citation and not the rank or the continuity.
11743        let cited = my_authority_citation(&community, &admin.public_key());
11744        assert!(cited.is_some(), "the seeded head yields a citation");
11745        let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11746        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() {
11747            relay.publish(&e, &community.relays).await.unwrap();
11748        }
11749        let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11750        assert!(out.updated.is_some(), "the cited rotation IS adopted");
11751    }
11752
11753    #[tokio::test]
11754    async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
11755        // CORD-06 §Failure-and-races: two DISTINCT authorized rotators mint the
11756        // same channel epoch concurrently (reachable — both hold MANAGE_CHANNELS).
11757        // Every follower must converge on the SAME key (the lexicographically
11758        // lowest), so the community never permanently forks. (Retaining the losing
11759        // fork's key for its race-window messages needs a multi-key-per-epoch
11760        // archive — a deferred refinement shared with v1; convergence, the
11761        // security-critical property, is what this pins.)
11762        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11763        let (_tmp, _guard, _owner) = init_test_db();
11764        let relay = MemoryRelay::new();
11765        let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
11766        let priv_id = ChannelId([0xC0; 32]);
11767        let key1 = [0xC1; 32];
11768        add_private_channel(&mut community, priv_id, key1, Epoch(1));
11769
11770        // Two admins (a, b) both hold the Admin role; I hold the channel key.
11771        let (a, b) = (Keys::generate(), Keys::generate());
11772        let role = Role::admin("ce".repeat(32));
11773        let roster = CommunityRoles {
11774            roles: vec![role.clone()],
11775            grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
11776        };
11777        seed_roster_with_heads(&community, &roster, 1_000);
11778
11779        // Both rotate 1 → 2, each delivering their OWN fresh key to me, off the
11780        // same prevcommit — a genuine same-epoch fork.
11781        let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11782        let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11783        let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11784        let key_a = [0x0A; 32];
11785        let key_b = [0xFB; 32]; // higher — a's must win regardless of publish order
11786        for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
11787            let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
11788            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() {
11789                relay.publish(&e, &community.relays).await.unwrap();
11790            }
11791        }
11792
11793        let session = SessionGuard::capture();
11794        let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
11795        let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
11796        assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
11797
11798        // A SECOND follower (fresh, holding the same epoch-1 key) converges identically.
11799        let mut peer = community.clone();
11800        if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
11801            c.key = Some(key1);
11802            c.epoch = Epoch(1);
11803        }
11804        // Re-run the same fold from the peer's identical starting point → same winner.
11805        let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
11806        assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
11807    }
11808
11809    #[tokio::test]
11810    async fn create_private_channel_refuses_a_member_without_manage_channels() {
11811        // The local mirror of the reader's gate: an unauthorized member is refused
11812        // BEFORE any publish (no floor pollution, no orphan key crate).
11813        let (bed, owner, member) = TestBed::new();
11814        bed.swap_to(&owner);
11815        let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
11816        send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11817
11818        bed.swap_to(&member);
11819        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11820        let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11821        let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
11822        assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
11823        let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
11824        assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
11825    }
11826
11827    // ── Audit regressions ────────────────────────────────────────────────────
11828
11829    #[tokio::test]
11830    async fn accept_rejects_a_bundle_with_a_forged_community_root() {
11831        // The eclipse: community_id commits only to (owner, salt) — both semi-public
11832        // — so a forged invite pairs the REAL triple with an attacker root, and every
11833        // plane derives from it. The join-time owner-genesis check must refuse.
11834        let (bed, owner, member) = TestBed::new();
11835        bed.swap_to(&owner);
11836        let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
11837
11838        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11839        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11840        forged.community_root = fake.clone();
11841        for ch in &mut forged.channels {
11842            ch.key = fake.clone();
11843        }
11844        let attacker = Keys::generate();
11845        let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
11846        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11847
11848        bed.swap_to(&member);
11849        let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11850        let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
11851        assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
11852        assert!(
11853            crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
11854            "a rejected join persists nothing"
11855        );
11856    }
11857
11858    #[tokio::test]
11859    async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
11860        // CORD-06 compaction re-wraps CURRENT heads with their original signatures,
11861        // so a rotated plane whose metadata an admin last edited carries no
11862        // owner-signed vsk-0. The join anchor there is the community-bound metadata
11863        // head plus any owner-signed edition under the same root.
11864        let (bed, owner, member) = TestBed::new();
11865        bed.swap_to(&owner);
11866        let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
11867        let general = community.channels[0].id;
11868
11869        let mut rotated = community.clone();
11870        rotated.community_root = [0x5A; 32];
11871        rotated.root_epoch = Epoch(1);
11872        let admin = Keys::generate();
11873        publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
11874        publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
11875
11876        bed.swap_to(&member);
11877        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11878        let session = SessionGuard::capture();
11879        let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11880        assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
11881    }
11882
11883    #[tokio::test]
11884    async fn only_an_actual_join_publishes_a_guestbook_join() {
11885        // A Guestbook Join is a member's own word that they JOINED. A re-accept of
11886        // a held community and a cross-device key sync (announce_join=false) must
11887        // both stay silent — each re-publish renders as "<user> has joined" spam.
11888        let (bed, owner, member) = TestBed::new();
11889        bed.swap_to(&owner);
11890        let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
11891
11892        let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
11893        async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
11894            let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
11895            relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
11896        }
11897        let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; // the owner's creation Join
11898
11899        bed.swap_to(&member);
11900        let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
11901        let session = SessionGuard::capture();
11902        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11903        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
11904
11905        accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11906        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
11907
11908        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11909        crate::db::community::delete_community(&cid_hex).unwrap();
11910        accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
11911        assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
11912    }
11913
11914    #[tokio::test]
11915    async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
11916        // The fallback's second half is load-bearing: a community-bound metadata
11917        // head alone is self-signable by anyone who knows the (public) community_id.
11918        let (bed, owner, member) = TestBed::new();
11919        bed.swap_to(&owner);
11920        let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
11921
11922        let mut rotated = community.clone();
11923        rotated.community_root = [0x5B; 32];
11924        rotated.root_epoch = Epoch(1);
11925        let attacker = Keys::generate();
11926        publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
11927
11928        bed.swap_to(&member);
11929        let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11930        let session = SessionGuard::capture();
11931        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11932        assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
11933    }
11934
11935    #[tokio::test]
11936    async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
11937        // The fallback applies to rotated planes only: at epoch 0 the spec guarantees
11938        // an owner-signed genesis, so owner material without it stays insufficient.
11939        let (bed, owner, member) = TestBed::new();
11940        bed.swap_to(&owner);
11941        let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
11942        let general = community.channels[0].id;
11943
11944        let mut fake = community.clone();
11945        fake.community_root = [0x5C; 32]; // epoch stays 0
11946        let admin = Keys::generate();
11947        publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11948        publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11949
11950        bed.swap_to(&member);
11951        let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11952        let session = SessionGuard::capture();
11953        let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11954        assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11955    }
11956
11957    #[tokio::test]
11958    async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11959        // A bundle can set a PUBLIC channel's grant key to the attacker's, so the
11960        // joiner addresses it at a plane only the attacker reads. The owner's genuine
11961        // public:false edition must override it on follow.
11962        let (_tmp, _guard, _owner) = init_test_db();
11963        let relay = MemoryRelay::new();
11964        let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11965        let general = community.channels[0].id;
11966        let mut poisoned = community.clone();
11967        poisoned.channels[0].private = true;
11968        poisoned.channels[0].key = Some([0x66; 32]);
11969        crate::db::community::save_community_v2(&poisoned).unwrap();
11970
11971        let session = SessionGuard::capture();
11972        let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11973        let ch = healed.channel(&general).unwrap();
11974        assert!(!ch.private, "the owner's public declaration overrides the bundle");
11975        assert_eq!(ch.key, None, "a healed public channel derives from the root");
11976    }
11977
11978    #[tokio::test]
11979    async fn a_deleted_channel_does_not_resurrect_on_reload() {
11980        // save_community_v2 must prune orphan channel rows, or a control-follow delete
11981        // reappears (with a stale key) on the next reload.
11982        let (_tmp, _guard, owner) = init_test_db();
11983        let relay = MemoryRelay::new();
11984        let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11985        let extra = ChannelId([0x77; 32]);
11986        let session = SessionGuard::capture();
11987        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11988        let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11989        assert!(with_extra.channel(&extra).is_some());
11990        publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11991        let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11992        assert!(after.channel(&extra).is_none());
11993
11994        let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11995        assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11996        assert_eq!(reloaded.channels.len(), 1);
11997    }
11998
11999    #[tokio::test]
12000    async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
12001        // channel_id is the sole DB primary key, so a bundle/replay reusing another
12002        // community's channel_id must NOT overwrite that row. It's skipped (not an
12003        // error — erroring would wedge all of this community's control persistence).
12004        let (_tmp, _guard, _owner) = init_test_db();
12005        let relay = MemoryRelay::new();
12006        let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
12007        let a_channel = a.channels[0].id;
12008        let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
12009        let b_channel = b.channels[0].id;
12010        // B's set includes a phantom whose id collides with A's channel.
12011        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() });
12012
12013        crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
12014        // A's channel row is untouched.
12015        let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
12016        assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
12017        assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
12018        // B keeps its own channel but never acquired a row for the foreign id.
12019        let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
12020        assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
12021        assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
12022    }
12023
12024    /// A single relay that CAPS every query below the page size (modelling a real
12025    /// relay's maxFilterLimit) and honors `until` — so the join-verify walk MUST
12026    /// paginate to reach an old genesis. MemoryRelay can't model this (it unions then
12027    /// truncates the whole set), which is why a MemoryRelay flood test gives false
12028    /// confidence about the production `LiveTransport` behaviour.
12029    struct CappedRelay {
12030        events: Vec<Event>,
12031        cap: usize,
12032    }
12033    #[async_trait::async_trait]
12034    impl Transport for CappedRelay {
12035        async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
12036        async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
12037            Ok(())
12038        }
12039        async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
12040            Ok(())
12041        }
12042        async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
12043            let mut m: Vec<Event> = self
12044                .events
12045                .iter()
12046                .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
12047                .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
12048                .cloned()
12049                .collect();
12050            m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); // newest first
12051            m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
12052            Ok(m)
12053        }
12054    }
12055
12056    #[tokio::test]
12057    async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
12058        // CORD-06 §3: a Refounder that cannot fold every Control Event must abort.
12059        // `until` is inclusive, so a page-wide block of same-second wraps is a wall
12060        // no cursor steps past — everything older (the genesis editions, a Banlist)
12061        // is unreachable. Compacting THAT view carries only what was read into the
12062        // new epoch, dropping the rest for every member, permanently. Any member can
12063        // build the wall: the plane key comes from the community root they hold.
12064        let (_tmp, _guard, _owner) = init_test_db();
12065        let memory = MemoryRelay::new();
12066        let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
12067        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
12068
12069        let rogue = Keys::generate();
12070        let mut events: Vec<Event> = Vec::new();
12071        for i in 0..FOLLOW_PAGE {
12072            let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
12073            let rumor = control::build_edition_rumor(
12074                rogue.public_key(),
12075                vsk::CHANNEL_METADATA,
12076                &[0xAB; 32],
12077                1,
12078                None,
12079                &content,
12080                9_000,
12081                None,
12082            );
12083            let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
12084            events.push(w);
12085        }
12086        let relay = CappedRelay { events, cap: FOLLOW_PAGE };
12087
12088        let err = refound_community(&relay, &community, &[])
12089            .await
12090            .expect_err("a plane that can't be read whole must never be compacted");
12091        assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
12092    }
12093
12094    #[tokio::test]
12095    async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
12096        // The join-verify DoS mitigation, tested against a relay that caps below PAGE
12097        // (production behaviour MemoryRelay hides): a rogue root-holder buries the
12098        // genesis under junk, and the `until`-walk must page past it. Uses fixed OLD
12099        // timestamps so `until = now` includes everything and the walk is deterministic.
12100        let (_tmp, _guard, owner) = init_test_db();
12101        let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
12102        let g = control::genesis(&owner, meta, 1_000).unwrap();
12103        let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
12104
12105        let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
12106        let rogue = Keys::generate();
12107        let mut events: Vec<Event> = g.wraps.to_vec();
12108        for i in 0..250u64 {
12109            let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
12110            let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
12111            events.push(wrap);
12112        }
12113        // Cap 100/query forces the walk across ~3 pages down to the genesis at ts 1000.
12114        let relay = CappedRelay { events, cap: 100 };
12115        let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
12116        assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
12117    }
12118
12119    #[tokio::test]
12120    async fn accept_parked_invite_joins_from_the_stored_bundle() {
12121        // The 3313 receive path: an invite is parked as its bundle JSON, then accepted
12122        // from the stored bundle (re-verifying the owner root over the network).
12123        let (bed, owner, member) = TestBed::new();
12124        bed.swap_to(&owner);
12125        let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
12126        let general = community.channels[0].id;
12127        send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
12128        let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12129        let bundle_json = serde_json::to_string(&bundle).unwrap();
12130        let inviter_hex = owner.keys.public_key().to_hex();
12131
12132        bed.swap_to(&member);
12133        let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
12134        assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
12135        assert!(joined.identity.verify());
12136        assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
12137        // The join seeded the verified fold as the member's initial floor, so their
12138        // first follow can't roll below the state the join just showed.
12139        let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12140        assert!(
12141            crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
12142            "the joiner's control floor is seeded from the join-time fold"
12143        );
12144
12145        // The Guestbook memberlist now folds both participants.
12146        bed.swap_to(&owner);
12147        let members = memberlist(&bed.relay, &community).await.unwrap();
12148        assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
12149    }
12150
12151    #[tokio::test]
12152    async fn accept_parked_invite_rejects_a_forged_root() {
12153        // A forged-root parked bundle (real identity triple, attacker-chosen root) fails
12154        // accept — the shared accept path re-verifies the owner root, so a parked invite
12155        // gets the same eclipse protection as a live one.
12156        let (_tmp, _guard, _owner) = init_test_db();
12157        let relay = MemoryRelay::new();
12158        let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
12159        let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
12160        let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
12161        forged.community_root = fake.clone();
12162        for ch in &mut forged.channels {
12163            ch.key = fake.clone();
12164        }
12165        let bundle_json = serde_json::to_string(&forged).unwrap();
12166
12167        let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
12168        assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
12169    }
12170
12171    #[test]
12172    fn v2_and_v1_bundles_are_distinguishable_by_parse() {
12173        // The protocol discriminator the facade list/accept relies on: a v2 bundle
12174        // (self-certifying: owner + owner_salt + community_root) parses; a v1-shaped
12175        // one does not, so a parked invite routes to the right accept path.
12176        let owner = Keys::generate();
12177        let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
12178        let hex = crate::simd::hex::bytes_to_hex_32;
12179        let v2 = invite::CommunityInvite {
12180            community_id: hex(&identity.community_id.0),
12181            owner: hex(&identity.owner_xonly),
12182            owner_salt: hex(&identity.owner_salt),
12183            community_root: hex(&[0x11; 32]),
12184            root_epoch: 0,
12185            channels: vec![],
12186            relays: vec!["wss://r".into()],
12187            name: "V2".into(),
12188            icon: None,
12189            expires_at: None,
12190            creator_npub: None,
12191            label: None,
12192            extra: Default::default(),
12193        };
12194        let v2_json = serde_json::to_string(&v2).unwrap();
12195        assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
12196        let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
12197        assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
12198    }
12199
12200    #[tokio::test]
12201    async fn verify_rejects_a_cross_community_owner_edition_replay() {
12202        // The eclipse-via-replay: an owner-signed edition from community X (eid == X.id)
12203        // rewrapped onto a FORGED community T's fake control plane must NOT authenticate
12204        // T. T's genesis has eid == T.id, so X's edition — a genuine owner signature but
12205        // a different eid — is not a valid proof of T's root. This is why "any owner
12206        // edition" is unsound and the eid==community_id genesis pin is required.
12207        let (_tmp, _guard, owner) = init_test_db();
12208
12209        // Community X (real), owned by `owner`.
12210        let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
12211        let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
12212        let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
12213
12214        // Forged community T: the real owner triple but an ATTACKER-chosen root.
12215        let t_identity = control::CommunityIdentity::mint(&owner.public_key());
12216        let fake_root = [0xEE; 32];
12217        let t = CommunityV2 {
12218            identity: t_identity,
12219            community_root: fake_root,
12220            root_epoch: Epoch(0),
12221            name: "T".into(),
12222            description: None,
12223            icon: None,
12224            banner: None,
12225            meta_custom: None,
12226            meta_extra: Default::default(),
12227            relays: vec!["wss://r".into()],
12228            channels: vec![],
12229            dissolved: false,
12230            created_at_ms: 0,
12231        };
12232        // Rewrap X's owner-signed genesis onto T's fake control plane (the attacker
12233        // controls the fake root, so they can derive its control group key).
12234        let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
12235        let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
12236        let relay = MemoryRelay::new();
12237        relay.publish(&replayed, &t.relays).await.unwrap();
12238
12239        let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
12240        assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
12241    }
12242
12243    /// LIVE smoke test (network) — ignored by default. Creates a v2 community on a
12244    /// REAL relay via `LiveTransport`, sends a message, fetches it back, and mints
12245    /// a public link. A fresh throwaway identity in an isolated temp data dir, so
12246    /// it never touches real accounts. Run explicitly:
12247    /// ```sh
12248    /// cargo test -p vector-core -- --ignored --nocapture live_smoke
12249    /// ```
12250    #[tokio::test]
12251    #[ignore = "hits a real relay over the network"]
12252    async fn live_smoke_create_send_fetch_on_a_real_relay() {
12253        use crate::community::transport::LiveTransport;
12254        use nostr_sdk::prelude::ToBech32;
12255
12256        let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
12257        let relays = vec![relay.clone()];
12258
12259        // Isolated account + data dir (a fresh throwaway key — never a real account).
12260        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
12261        crate::db::close_database();
12262        crate::db::clear_id_caches();
12263        let tmp = tempfile::tempdir().unwrap();
12264        // Bring your own key (VECTOR_SMOKE_NSEC) to create a community you can log
12265        // into elsewhere; otherwise a fresh throwaway.
12266        let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
12267            Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
12268            Err(_) => Keys::generate(),
12269        };
12270        let npub = keys.public_key().to_bech32().unwrap();
12271        // Off by default (never leak secrets from a committed test); set
12272        // VECTOR_SMOKE_PRINT_NSEC=1 to print the owner nsec for cross-client login.
12273        if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
12274            println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
12275        }
12276        std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
12277        crate::db::set_app_data_dir(tmp.path().to_path_buf());
12278        crate::db::set_current_account(npub.clone()).unwrap();
12279        crate::db::init_database(&npub).unwrap();
12280        crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
12281        crate::state::set_my_public_key(keys.public_key());
12282        println!("[smoke] throwaway identity {npub}");
12283
12284        // A live client (LiveTransport rides the global NOSTR_CLIENT + warms relays).
12285        let client = crate::nostr_client_builder().build();
12286        client.add_managed_relay(relay.as_str()).await.ok();
12287        client.connect().await;
12288        crate::state::set_nostr_client(client);
12289        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
12290
12291        // Create → send → fetch-back → verify.
12292        let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
12293        let general = community.channels[0].id;
12294        println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
12295
12296        let text = "hello from a Vector Concord v2 live smoke test";
12297        let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
12298        println!("[smoke] sent message {sent_id}");
12299
12300        // Give the relay a moment to store + be ready to serve it.
12301        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
12302
12303        let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
12304        let texts: Vec<String> = page
12305            .iter()
12306            .filter_map(|f| match &f.event {
12307                ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
12308                _ => None,
12309            })
12310            .collect();
12311        println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
12312        assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
12313
12314        // Mint a shareable v2 link (the thing a bot hands out).
12315        let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
12316        println!("[smoke] invite link: {}", link.url);
12317        println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
12318    }
12319
12320    #[tokio::test]
12321    async fn chat_ops_react_edit_delete_round_trip() {
12322        let (bed, owner, _member) = TestBed::new();
12323        bed.swap_to(&owner);
12324        let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
12325        let general = community.channels[0].id;
12326        let me_hex = owner.keys.public_key().to_hex();
12327
12328        let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
12329        send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
12330            .await
12331            .unwrap();
12332        send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
12333        send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
12334
12335        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12336        let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
12337        let mut saw = (false, false, false);
12338        for f in &page {
12339            match &f.event {
12340                ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
12341                    assert_eq!(emoji, ":fire:");
12342                    assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
12343                    saw.0 = true;
12344                }
12345                ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
12346                    assert_eq!(new_content, "edited");
12347                    saw.1 = true;
12348                }
12349                ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
12350                _ => {}
12351            }
12352        }
12353        assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
12354    }
12355
12356    #[tokio::test]
12357    async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
12358        let (bed, owner, _member) = TestBed::new();
12359        bed.swap_to(&owner);
12360        let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
12361        let general = community.channels[0].id;
12362        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12363
12364        // A live subscriber sees the 21059 wrap and it opens as Typing…
12365        let mut sub = bed.relay.subscribe(Query {
12366            kinds: vec![stream::KIND_WRAP_EPHEMERAL],
12367            authors: vec![group.pk_hex()],
12368            ..Default::default()
12369        });
12370        send_typing(&bed.relay, &community, &general).await.unwrap();
12371        let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
12372        let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
12373            Ok(ChatEvent::Typing { opened }) => opened,
12374            other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
12375        };
12376
12377        // …while nothing durable is stored (relays never keep the ephemeral tier),
12378        // so channel history stays free of typing noise…
12379        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12380        assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
12381
12382        // …and no scrub key is retained (there is no durable wrap to ever delete).
12383        assert!(
12384            crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
12385            "ephemeral sends must not retain scrub keys"
12386        );
12387    }
12388
12389    #[tokio::test]
12390    async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
12391        let (bed, owner, _member) = TestBed::new();
12392        bed.swap_to(&owner);
12393        let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
12394        let general = community.channels[0].id;
12395        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12396
12397        let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
12398
12399        // Retained: the row maps the rumor id to the exact published wrap, holds the
12400        // key that SIGNED that wrap (same-author NIP-09), and the relay set.
12401        let (keys, outer_hex, relays) =
12402            crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
12403        assert_eq!(relays, community.relays);
12404        let wrap_query = Query {
12405            kinds: vec![stream::KIND_WRAP],
12406            authors: vec![group.pk_hex()],
12407            ..Default::default()
12408        };
12409        let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12410        let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
12411        assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
12412
12413        // Reactions ride the same retention (revoke_reaction's relay-nuke layer).
12414        let me_hex = owner.keys.public_key().to_hex();
12415        let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
12416            .await
12417            .unwrap();
12418        assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
12419
12420        // The shared v1 delete path (Layer 1 of delete_community_message / revoke_reaction)
12421        // scrubs the wrap off the relay via the retained key, then consumes the row.
12422        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12423        assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
12424        let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12425        assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
12426    }
12427
12428    #[tokio::test]
12429    async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
12430        let (bed, owner, _member) = TestBed::new();
12431        bed.swap_to(&owner);
12432        let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
12433        let general = community.channels[0].id;
12434        let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12435
12436        // Simulate a pre-retention / other-device send: our message on the relay,
12437        // but no local mapping row.
12438        let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
12439        crate::db::community::delete_message_key(&id).unwrap();
12440        assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
12441
12442        // A stranger member's message rides the same channel.
12443        let mkeys = Keys::generate();
12444        let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
12445        let foreign_id = rumor.id.unwrap().to_hex();
12446        let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
12447        bed.relay.publish(&fw, &community.relays).await.unwrap();
12448
12449        // One history open re-derives the mapping for the OWN message…
12450        fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12451        let (keys, _outer, relays) =
12452            crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
12453        assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
12454        assert_eq!(relays, community.relays);
12455
12456        // …and never manufactures one for a foreign author.
12457        assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
12458
12459        // The healed row is a working full delete: the shared path scrubs the wrap.
12460        crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12461        let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12462        assert!(
12463            !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
12464            "healed message scrubbed from the relay"
12465        );
12466    }
12467
12468    #[tokio::test]
12469    async fn send_chat_message_threads_the_reply_and_extra_tags() {
12470        let (bed, owner, _member) = TestBed::new();
12471        bed.swap_to(&owner);
12472        let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
12473        let general = community.channels[0].id;
12474        let me_hex = owner.keys.public_key().to_hex();
12475
12476        let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
12477        let imeta = nostr_sdk::prelude::Tag::custom(
12478            "imeta",
12479            ["url https://e/blob".to_string(), "m image/png".to_string()],
12480        );
12481        let child_id = send_chat_message(
12482            &bed.relay, &community, &general, "child",
12483            Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
12484        )
12485        .await
12486        .unwrap();
12487
12488        let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12489        let child = page
12490            .iter()
12491            .find_map(|f| match &f.event {
12492                ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
12493                _ => None,
12494            })
12495            .expect("the reply message round-trips");
12496        let reply = child.1.as_ref().expect("the reply reference is carried");
12497        assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
12498        assert_eq!(reply.author, Some(owner.keys.public_key()));
12499        assert!(
12500            child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
12501            "the imeta attachment tag rides the rumor verbatim"
12502        );
12503    }
12504
12505    #[tokio::test]
12506    async fn a_kick_needs_kick_authority_and_removes_the_target() {
12507        let (bed, owner, member) = TestBed::new();
12508        bed.swap_to(&owner);
12509        let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
12510
12511        // The target announces a Join (as an accepted invite would).
12512        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
12513        let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
12514        let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
12515        bed.relay.publish(&wrap, &bed.relays).await.unwrap();
12516        let before = memberlist(&bed.relay, &community).await.unwrap();
12517        assert!(before.contains(&member.keys.public_key()), "the join lands first");
12518
12519        // An unprivileged member's kick of the owner is refused locally…
12520        bed.swap_to(&member);
12521        let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
12522        assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
12523
12524        // …and the owner (supreme, no grant needed) kicks the member out.
12525        bed.swap_to(&owner);
12526        kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12527        let after = memberlist(&bed.relay, &community).await.unwrap();
12528        assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
12529        assert!(after.contains(&owner.keys.public_key()), "the owner remains");
12530    }
12531
12532    #[tokio::test]
12533    async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
12534        // The self-eviction race: on a REJOIN the guestbook store starts empty while the
12535        // control fold has already re-derived the member's old ban mark, so the MEMBERLIST
12536        // legitimately excludes them for that window. A stale Kick landing there used to
12537        // read as an authorized eviction and the client nuked its own community.
12538        let (bed, owner, member) = TestBed::new();
12539        bed.swap_to(&owner);
12540        let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
12541        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12542        let (o, m) = (owner.keys.public_key(), member.keys.public_key());
12543        let join = |at: u64, id: u8| guestbook::GuestbookEvent {
12544            rumor_id: [id; 32],
12545            entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
12546        };
12547        let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
12548            rumor_id: [id; 32],
12549            entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
12550        };
12551
12552        // An authorized kick after their join stands.
12553        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
12554        assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
12555
12556        // A rejoin supersedes it — latest entry wins (CORD-02 §5).
12557        crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
12558        assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
12559
12560        // The catch-up window itself: nothing folded yet decides nothing.
12561        crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
12562        assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
12563
12564        // And the memberlist is NOT a substitute: with the store empty it excludes them,
12565        // which is exactly the false positive this verdict replaced.
12566        assert!(
12567            !stored_memberlist(&community).unwrap().contains(&m),
12568            "the memberlist excludes an un-caught-up member — why it can't gate a kick"
12569        );
12570    }
12571
12572    /// Seed a roster the way production does: `follow_control` writes the roster
12573    /// AND the folded edition heads in one pass, so a citation against a grant is
12574    /// resolvable. Seeding the roster alone yields a client that can never satisfy
12575    /// any `vac` — a shape no v2 production path produces.
12576    fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
12577        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12578        crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
12579        for g in &roster.grants {
12580            let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
12581            let eid = super::super::derive::grant_locator(community.id(), &m);
12582            let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
12583            crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
12584        }
12585    }
12586
12587    /// Publish an edition CITING a specific grant version (CORD-04 §5's `vac`).
12588    async fn publish_grant_citing(
12589        relay: &MemoryRelay,
12590        community: &CommunityV2,
12591        signer: &Keys,
12592        member: &PublicKey,
12593        role_ids: Vec<String>,
12594        version: u64,
12595        citation: Option<&crate::community::edition::AuthorityCitation>,
12596    ) {
12597        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12598        let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
12599        let prev = head_hash_on_relay(relay, community, &eid).await;
12600        let grant = MemberGrant { member: member.to_hex(), role_ids };
12601        let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
12602        let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
12603        let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
12604        relay.publish(&wrap, &community.relays).await.unwrap();
12605    }
12606
12607    #[tokio::test]
12608    async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
12609        // CORD-04 §5 on the CONTROL PLANE: "a verifier won't act on the edition
12610        // until it has synced at least that Grant". The citation resolves against
12611        // the heads THIS fold accepted — an external floor would refuse every
12612        // non-owner edition on a bootstrap and the roster could never fold.
12613        let (bed, owner, admin) = TestBed::new();
12614        bed.swap_to(&owner);
12615        let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
12616        let admin_pk = admin.keys.public_key();
12617        let rid = "c3".repeat(32);
12618        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
12619        publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
12620
12621        // The admin grants a bystander, citing NOTHING.
12622        // A LOWER role (position 5) — an admin at position 1 may grant beneath
12623        // themselves but never at their own rank (equal cannot act on equal).
12624        let low_rid = "c4".repeat(32);
12625        let mut low = admin_role(&low_rid, Permissions::admin().0);
12626        low.position = 5;
12627        publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
12628
12629        let bystander = Keys::generate().public_key();
12630        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
12631        let view = fetch_authority(&bed.relay, &community).await;
12632        assert!(
12633            !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12634            "an uncited non-owner edition is not folded"
12635        );
12636        // The owner's own editions still fold — supreme cites nothing.
12637        assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
12638
12639        // Same edition, now citing the admin's real grant: honored. (follow_control
12640        // is what PERSISTS the folded heads a citation is built from.)
12641        let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
12642        let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
12643        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12644        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12645        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12646        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12647        publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
12648
12649        let view = fetch_authority(&bed.relay, &community).await;
12650        assert!(
12651            view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12652            "the same edition WITH its synced citation folds"
12653        );
12654    }
12655
12656    #[tokio::test]
12657    async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
12658        // The invite is deliberately ungated, so a fresh Join can arrive seconds
12659        // BEFORE the unban edition. It must reach the store (banned = a fold
12660        // verdict, not a storage verdict) so the unban resurrects the member —
12661        // dropped at ingest, they stayed invisible forever.
12662        let (bed, owner, member) = TestBed::new();
12663        bed.swap_to(&owner);
12664        let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
12665        let member_pk = member.keys.public_key();
12666        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12667
12668        // Locally banned (edition folded at t=1000s), with the outliving mark.
12669        crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
12670        crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
12671
12672        // Their Join lands 60s after the ban mark, while the banlist still says banned.
12673        let join = guestbook::GuestbookEvent {
12674            rumor_id: [9u8; 32],
12675            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
12676        };
12677        assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
12678        assert!(
12679            !stored_memberlist(&community).unwrap().contains(&member_pk),
12680            "while banned, the fold keeps them out"
12681        );
12682
12683        // The unban folds: same store, no refetch needed — the Join resurrects them.
12684        crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
12685        assert!(
12686            stored_memberlist(&community).unwrap().contains(&member_pk),
12687            "after the unban the raced Join makes them a member again"
12688        );
12689    }
12690
12691    #[tokio::test]
12692    async fn a_stale_root_admin_write_is_refused_not_misdirected() {
12693        // The ban→unban race: a Ban's refound buries the old root over several
12694        // publishes while a concurrently-issued command still holds the
12695        // pre-commit struct. That unban used to land on the buried control
12696        // plane — "succeeding" while no reader would ever fold it — and a
12697        // concurrently-minted invite stranded its joiner on the dead epoch.
12698        let (bed, owner, member) = TestBed::new();
12699        bed.swap_to(&owner);
12700        let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
12701        let member_pk = member.keys.public_key();
12702
12703        set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
12704        let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
12705
12706        // The stale-struct unban is REFUSED (retryable), never misdirected.
12707        let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
12708        assert!(err.contains("re-founded"), "unban: {err}");
12709        // A stale invite must not mint dead-epoch key material.
12710        let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
12711        assert!(err.contains("re-founded"), "invite: {err}");
12712        // Neither is a kick allowed to ride the buried guestbook.
12713        let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
12714        assert!(err.contains("re-founded"), "kick: {err}");
12715
12716        // The retry path: a fresh load lands the unban on the LIVING plane.
12717        let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12718        set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
12719        let view = fetch_authority(&bed.relay, &fresh).await;
12720        assert!(view.banned.is_empty(), "the retried unban actually unbans");
12721    }
12722
12723    #[tokio::test]
12724    async fn an_uncited_kick_from_an_admin_is_not_honored() {
12725        // CORD-04 §5: a non-owner authority action must name the Grant it acts
12726        // under, and the reader refuses until it holds that Grant. Emitting the
12727        // `vac` without checking it buys nothing — a demoted admin's kick would
12728        // still land on any client that hadn't synced the demotion.
12729        let (bed, owner, member) = TestBed::new();
12730        bed.swap_to(&owner);
12731        let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
12732        let admin = Keys::generate();
12733        let member_pk = member.keys.public_key();
12734        grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
12735
12736        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12737        let view = fetch_authority(&bed.relay, &community).await;
12738        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
12739
12740        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
12741        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12742        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12743        let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12744
12745        let joined = guestbook::GuestbookEvent {
12746            rumor_id: [1u8; 32],
12747            entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
12748        };
12749        let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
12750            rumor_id: [id; 32],
12751            entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
12752        };
12753        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
12754        let empty_bans = std::collections::BTreeSet::new();
12755        let empty_marks = std::collections::BTreeMap::new();
12756        let fold = |evs: &[guestbook::GuestbookEvent]| {
12757            fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
12758        };
12759
12760        assert!(
12761            fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
12762            "an uncited kick from an admin is not honored"
12763        );
12764        assert!(
12765            !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
12766            "the same kick WITH its synced citation removes them"
12767        );
12768    }
12769
12770    #[tokio::test]
12771    async fn kicking_an_admin_strips_their_roles_first() {
12772        // CORD-04 §6 composition: Role Removal THEN the directive. Kicking without the
12773        // strip leaves the target out of the memberlist but still holding every
12774        // management bit, so every client keeps honoring their control editions.
12775        let (bed, owner, member) = TestBed::new();
12776        bed.swap_to(&owner);
12777        let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
12778        let member_pk = member.keys.public_key();
12779        let member_hex = member_pk.to_hex();
12780        let owner_hex = owner.keys.public_key().to_hex();
12781
12782        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12783        assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
12784
12785        kick_member(&bed.relay, &community, &member_pk).await.unwrap();
12786
12787        let view = fetch_authority(&bed.relay, &community).await;
12788        assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
12789        assert!(
12790            !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
12791            "a kicked admin holds no bit"
12792        );
12793        assert!(
12794            !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
12795            "and the directive still removed them"
12796        );
12797    }
12798
12799    #[tokio::test]
12800    async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
12801        let (bed, owner, member) = TestBed::new();
12802        bed.swap_to(&owner);
12803        let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
12804        let member_pk = member.keys.public_key();
12805        let member_hex = member_pk.to_hex();
12806        let owner_hex = owner.keys.public_key().to_hex();
12807
12808        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12809        let view = fetch_authority(&bed.relay, &community).await;
12810        assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
12811        assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
12812
12813        // A second grant (any device) converges on the SAME role entity — and a
12814        // repeat is a no-op, not a version bump.
12815        let second = Keys::generate().public_key();
12816        grant_admin(&bed.relay, &community, &second).await.unwrap();
12817        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12818        let view = fetch_authority(&bed.relay, &community).await;
12819        assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
12820        assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
12821        let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
12822        assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
12823
12824        // Revoke strips ONLY the admin role and de-authorizes.
12825        revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
12826        let view = fetch_authority(&bed.relay, &community).await;
12827        assert!(!view.roles.is_admin(&member_hex), "revoked");
12828        assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
12829        assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
12830    }
12831
12832    #[tokio::test]
12833    async fn follow_control_persists_the_roster_for_sync_local_reads() {
12834        let (bed, owner, member) = TestBed::new();
12835        bed.swap_to(&owner);
12836        let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
12837        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12838        let member_hex = member.keys.public_key().to_hex();
12839        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12840
12841        // The passive follow folds + persists; the read is then LOCAL (v1 parity).
12842        let session = crate::state::SessionGuard::capture();
12843        follow_control(&bed.relay, &community, &session).await.unwrap();
12844        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12845        assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
12846
12847        // A withholding relay serves nothing — an empty fold raises no gap flag, and
12848        // the stored roster must be RETAINED, never wiped.
12849        let withholding = MemoryRelay::new();
12850        let _ = follow_control(&withholding, &community, &session).await;
12851        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12852        assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
12853
12854        // A real revocation (a NEWER grant edition) does replace it.
12855        revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12856        follow_control(&bed.relay, &community, &session).await.unwrap();
12857        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12858        assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
12859    }
12860
12861    #[tokio::test]
12862    async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
12863        let (bed, owner, member) = TestBed::new();
12864        bed.swap_to(&owner);
12865        let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
12866
12867        bed.swap_to(&member);
12868        let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
12869        assert!(err.contains("owner"), "refused before any publish: {err}");
12870
12871        // The deterministic admin-role entity stays unsquatted — the owner's later
12872        // legitimate mint is version 1 and folds cleanly.
12873        bed.swap_to(&owner);
12874        let view = fetch_authority(&bed.relay, &community).await;
12875        assert!(view.roles.roles.is_empty(), "no role edition landed");
12876        grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12877        let view = fetch_authority(&bed.relay, &community).await;
12878        assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
12879    }
12880
12881    #[tokio::test]
12882    async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
12883        let (bed, owner, member) = TestBed::new();
12884        bed.swap_to(&owner);
12885        let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
12886        let member_pk = member.keys.public_key();
12887
12888        // The member already holds a Mod role, granted through the real send path
12889        // (so this device's floors track both entities).
12890        let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
12891        set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
12892        grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
12893
12894        // A relay that withholds the control plane must refuse the merge — a blind
12895        // push would erase the Mod role at a higher version.
12896        let withholding = MemoryRelay::new();
12897        let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
12898        assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
12899
12900        // Against the full relay the merge preserves the Mod role.
12901        grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12902        let view = fetch_authority(&bed.relay, &community).await;
12903        let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
12904        assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
12905        assert!(grant.role_ids.contains(&mod_rid));
12906    }
12907
12908    #[tokio::test]
12909    async fn fetch_authority_reflects_a_granted_admin() {
12910        let (bed, owner, member) = TestBed::new();
12911        bed.swap_to(&owner);
12912        let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
12913        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
12914        publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12915        publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
12916
12917        let view = fetch_authority(&bed.relay, &community).await;
12918        let member_hex = member.keys.public_key().to_hex();
12919        assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
12920        assert!(
12921            view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
12922            "an ADMIN_ALL grant carries KICK"
12923        );
12924        assert!(view.banned.is_empty());
12925    }
12926
12927    // ── Pins (CORD-04 §7) — fold, authority, and the silent Admin widening ──
12928
12929    /// The full wire round trip: a real message pinned into a published
12930    /// edition, folded by the control follow, persisted, and read back proven.
12931    #[tokio::test]
12932    async fn a_pin_edition_folds_persists_and_reads_back() {
12933        let (_tmp, _guard, _owner) = init_test_db();
12934        let relay = MemoryRelay::new();
12935        let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
12936        let general = community.channels[0].id;
12937        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
12938
12939        send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
12940        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12941        let opened = page
12942            .iter()
12943            .find_map(|f| match &f.event {
12944                ChatEvent::Message { opened, .. } => Some(opened.clone()),
12945                _ => None,
12946            })
12947            .expect("the sent message reads back");
12948
12949        let ch = community.channels[0].clone();
12950        let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
12951        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12952        let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
12953
12954        let session = crate::state::SessionGuard::capture();
12955        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12956        publish_control_edition(&relay, &community, &session, vsk::PINS, &eid, &content).await.unwrap();
12957        follow_control(&relay, &community, &session).await.unwrap();
12958
12959        let read = read_channel_pins(&community, &general).unwrap();
12960        assert!(!read.sealed);
12961        assert!(read.version >= 1, "the folded head persisted");
12962        assert_eq!(read.pins.len(), 1);
12963        assert_eq!(read.pins[0].content, "pin-worthy");
12964        assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
12965    }
12966
12967    /// CORD-04 §5: a pins edition from an author holding no PIN_MESSAGES never
12968    /// becomes the head — the fold's authority gate covers the new entity.
12969    #[tokio::test]
12970    async fn an_unauthorized_pin_edition_never_folds() {
12971        let (_tmp, _guard, _owner) = init_test_db();
12972        let relay = MemoryRelay::new();
12973        let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
12974        let general = community.channels[0].id;
12975
12976        let rogue = Keys::generate();
12977        let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12978        let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12979        let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
12980        let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
12981        relay.publish(&wrap, &community.relays).await.unwrap();
12982
12983        let session = crate::state::SessionGuard::capture();
12984        follow_control(&relay, &community, &session).await.unwrap();
12985        let read = read_channel_pins(&community, &general).unwrap();
12986        assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
12987        assert!(read.pins.is_empty());
12988    }
12989
12990    /// The silent owner-side widening: a pre-pins Admin role (founding mask)
12991    /// gains PIN_MESSAGES as one edition of the same entity; idempotent after.
12992    #[tokio::test]
12993    async fn owner_silently_widens_a_legacy_admin_role() {
12994        let (_tmp, _guard, owner) = init_test_db();
12995        let relay = MemoryRelay::new();
12996        let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
12997        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12998        let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
12999
13000        // An Admin role exactly as a pre-pins build published it.
13001        let legacy = Role {
13002            role_id: rid.clone(),
13003            name: "Admin".into(),
13004            position: 1,
13005            permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
13006            scope: RoleScope::Server,
13007            color: 0,
13008        };
13009        publish_role(&relay, &community, &owner, &legacy, 1).await;
13010        let session = crate::state::SessionGuard::capture();
13011        follow_control(&relay, &community, &session).await.unwrap();
13012
13013        assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
13014        follow_control(&relay, &community, &session).await.unwrap();
13015        let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
13016        let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
13017        assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
13018        assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
13019
13020        // Second call: nothing left to widen.
13021        assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
13022    }
13023
13024    /// §7 deletion duty: the author-curator's own deleted message leaves the
13025    /// list as an immediate omitting edition.
13026    #[tokio::test]
13027    async fn the_deletion_duty_omits_a_pinned_message() {
13028        let (_tmp, _guard, _owner) = init_test_db();
13029        let relay = MemoryRelay::new();
13030        let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
13031        let general = community.channels[0].id;
13032        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
13033
13034        let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
13035        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13036        let opened = page
13037            .iter()
13038            .find_map(|f| match &f.event {
13039                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13040                _ => None,
13041            })
13042            .unwrap();
13043        let ch = community.channels[0].clone();
13044        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
13045        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13046        let session = crate::state::SessionGuard::capture();
13047        publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13048        assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
13049
13050        // The author holds the bit (owner) → the duty publishes the omission at once.
13051        run_pin_duty(&relay, &ch_hex, &rumor_id, None, crate::state::SessionGuard::capture()).await.unwrap();
13052        let after = read_channel_pins(&community, &general).unwrap();
13053        assert!(after.pins.is_empty(), "the omitting edition landed");
13054        assert!(after.version >= 2, "a NEW edition, not a local erase");
13055    }
13056
13057    /// §7 edit duty: an edited pinned message gets its proof bundle refreshed,
13058    /// so keyless readers see the revision — and the duty is idempotent.
13059    #[tokio::test]
13060    async fn the_edit_duty_refreshes_a_pinned_proof() {
13061        let (_tmp, _guard, _owner) = init_test_db();
13062        let relay = MemoryRelay::new();
13063        let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
13064        let general = community.channels[0].id;
13065        let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
13066
13067        let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
13068        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13069        let opened = page
13070            .iter()
13071            .find_map(|f| match &f.event {
13072                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13073                _ => None,
13074            })
13075            .unwrap();
13076        let ch = community.channels[0].clone();
13077        let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
13078        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13079        let session = crate::state::SessionGuard::capture();
13080        publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13081
13082        send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
13083        let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13084        let edit_opened = page
13085            .iter()
13086            .find_map(|f| match &f.event {
13087                ChatEvent::Edit { opened, .. } => Some(opened.clone()),
13088                _ => None,
13089            })
13090            .expect("the edit reads back");
13091
13092        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone()), crate::state::SessionGuard::capture()).await.unwrap();
13093        let after = read_channel_pins(&community, &general).unwrap();
13094        assert_eq!(after.pins.len(), 1);
13095        assert_eq!(
13096            after.pins[0].content, "second thoughts",
13097            "the refreshed bundle proves the revision"
13098        );
13099        let v = after.version;
13100
13101        // Same revision again → monotonic guard, no new edition.
13102        run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened), crate::state::SessionGuard::capture()).await.unwrap();
13103        assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
13104    }
13105
13106    /// §7 Rotator duty: a private-channel rotation republishes the Pin List
13107    /// sealed under the NEW epoch — a member who joins after the rotation
13108    /// (holding only the new key) must not read the channel's pins as dark.
13109    #[tokio::test]
13110    async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
13111        let (_tmp, _guard, _owner) = init_test_db();
13112        let relay = MemoryRelay::new();
13113        let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
13114        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13115        let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
13116        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13117        let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
13118
13119        let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
13120        let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
13121        let opened = page
13122            .iter()
13123            .find_map(|f| match &f.event {
13124                ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13125                _ => None,
13126            })
13127            .unwrap();
13128        let ch = community.channel(&chan).unwrap().clone();
13129        let old_epoch = ch.epoch.0;
13130        let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
13131        let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13132        let session = crate::state::SessionGuard::capture();
13133        publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13134        let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
13135
13136        // Rotate the channel away from a (never-granted) member.
13137        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
13138        rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &Keys::generate().public_key())
13139            .await
13140            .unwrap();
13141
13142        // The stored head is a NEW edition, sealed under the NEW epoch.
13143        let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
13144        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
13145        assert_eq!(
13146            parsed["epoch"].as_str().unwrap(),
13147            (old_epoch + 1).to_string(),
13148            "the reseal names the rotated epoch"
13149        );
13150        assert!(version > v_before, "a real edition, not a local rewrite");
13151
13152        // The rotator's own post-rotation view still verifies the pin.
13153        let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13154        let read = read_channel_pins(&community, &chan).unwrap();
13155        assert!(!read.sealed);
13156        assert_eq!(read.pins.len(), 1);
13157        assert_eq!(read.pins[0].content, "sealed wisdom");
13158    }
13159
13160    /// The production ban-eraser: set_banlist must ECHO its published list
13161    /// into the local cache immediately. Before this, the cache moved only on
13162    /// a successful control fold — and a composing caller (ban = banlist →
13163    /// grant strip → refound) whose refound tripped re-read the stale list,
13164    /// so each of 19 real bans erased its predecessors.
13165    #[tokio::test]
13166    async fn a_published_banlist_echoes_locally_before_any_fold() {
13167        let (_tmp, _guard, _owner) = init_test_db();
13168        let relay = MemoryRelay::new();
13169        let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
13170        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13171        let spammer_a = "aa".repeat(32);
13172        let spammer_b = "bb".repeat(32);
13173
13174        // Ban A. NO fold runs — the cache must hold the publish regardless.
13175        set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
13176        assert_eq!(
13177            crate::db::community::get_community_banlist(&cid_hex).unwrap(),
13178            vec![spammer_a.clone()],
13179            "the publish echoes without waiting for a fold"
13180        );
13181
13182        // Ban B composes from the cache, exactly as the SDK does.
13183        let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13184        list.push(spammer_b.clone());
13185        set_banlist(&relay, &community, &list).await.unwrap();
13186        let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13187        assert!(
13188            held.contains(&spammer_a) && held.contains(&spammer_b),
13189            "sequential bans UNION; the second must not erase the first: {held:?}"
13190        );
13191
13192        // The wire agrees: a real fold confirms rather than regresses.
13193        let session = crate::state::SessionGuard::capture();
13194        follow_control(&relay, &community, &session).await.unwrap();
13195        let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13196        assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
13197    }
13198}